diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34b5bb5c0b..c18e49f4a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -488,6 +488,13 @@ jobs: RUST_BACKTRACE: '1' RUST_MIN_STACK: 8388608 + - name: Test completion integration + run: vp env exec --node ${{ matrix.test-node }} node packages/tools/src/test-completion.ts + env: + VP_COMPLETION_CONSUMER_BIN: ${{ github.workspace }}/target/${{ matrix.target }}/release/vp${{ runner.os == 'Windows' && '.exe' || '' }} + RUST_BACKTRACE: '1' + RUST_MIN_STACK: 8388608 + - name: Test global package install (powershell) if: ${{ runner.os != 'Linux' }} shell: pwsh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1a79a9c18a..c742dfe1a3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -93,6 +93,12 @@ To create a release build of Vite+ and all upstream dependencies, run: just build ``` +## Completion integration tests + +After building the JavaScript packages, run `pnpm test:completion` for the metadata and startup integration tests. It builds `vp_global_cli` and uses Cargo's reported executable. To use a prebuilt binary, set `VP_COMPLETION_CONSUMER_BIN` to its exact path; an invalid path fails the suite. + +CI runs this suite separately with the downloaded native binary. Ordinary `pnpm test:unit` does not build Rust or discover the `*.integration.ts` files. + ## Git Hooks (optional) Pre-commit checks are opt-in. Run `vp config` after a build to install the hook dispatcher; each commit then runs `vp staged` on staged files. `vp staged` loads its config from `vite.config.ts` and needs the built JS packages, so build first. Opt out with `vp hooks disable`, or skip one run with `VP_GIT_HOOKS=0`. If husky was active in your clone before, run `git config --unset core.hooksPath` once first. diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-alpha/artifacts/bin.js b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-alpha/artifacts/bin.js new file mode 100644 index 0000000000..91233a7ecb --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-alpha/artifacts/bin.js @@ -0,0 +1 @@ +throw new Error('Completion must never execute JavaScript'); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-alpha/artifacts/completion/test.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-alpha/artifacts/completion/test.json new file mode 100644 index 0000000000..367019d8c9 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-alpha/artifacts/completion/test.json @@ -0,0 +1,24 @@ +{ + "schemaVersion": 1, + "vitePlusVersion": "1.2.0", + "vitestVersion": "5.0.5", + "globalOptions": [ + { + "flags": [ + "--alpha" + ], + "description": "alpha option", + "valueMode": "none" + } + ], + "commands": [ + { + "name": "", + "options": [] + }, + { + "name": "run", + "options": [] + } + ] +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-alpha/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-alpha/package.json new file mode 100644 index 0000000000..6afbba7713 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-alpha/package.json @@ -0,0 +1 @@ +{"name": "vite-plus", "version": "1.2.0"} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-alpha/vitest-package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-alpha/vitest-package.json new file mode 100644 index 0000000000..9ebdf56052 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-alpha/vitest-package.json @@ -0,0 +1 @@ +{"name": "vitest", "version": "5.0.5"} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-beta/artifacts/bin.js b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-beta/artifacts/bin.js new file mode 100644 index 0000000000..91233a7ecb --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-beta/artifacts/bin.js @@ -0,0 +1 @@ +throw new Error('Completion must never execute JavaScript'); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-beta/artifacts/completion/test.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-beta/artifacts/completion/test.json new file mode 100644 index 0000000000..c4e0ea7f4f --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-beta/artifacts/completion/test.json @@ -0,0 +1,24 @@ +{ + "schemaVersion": 1, + "vitePlusVersion": "1.3.0", + "vitestVersion": "5.1.0", + "globalOptions": [ + { + "flags": [ + "--beta" + ], + "description": "beta option", + "valueMode": "none" + } + ], + "commands": [ + { + "name": "", + "options": [] + }, + { + "name": "run", + "options": [] + } + ] +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-beta/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-beta/package.json new file mode 100644 index 0000000000..36a663d262 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-beta/package.json @@ -0,0 +1 @@ +{"name": "vite-plus", "version": "1.3.0"} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-beta/vitest-package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-beta/vitest-package.json new file mode 100644 index 0000000000..4775c106c2 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-beta/vitest-package.json @@ -0,0 +1 @@ +{"name": "vitest", "version": "5.1.0"} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-global/artifacts/bin.js b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-global/artifacts/bin.js new file mode 100644 index 0000000000..91233a7ecb --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-global/artifacts/bin.js @@ -0,0 +1 @@ +throw new Error('Completion must never execute JavaScript'); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-global/artifacts/completion/test.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-global/artifacts/completion/test.json new file mode 100644 index 0000000000..cc5e08f064 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-global/artifacts/completion/test.json @@ -0,0 +1,24 @@ +{ + "schemaVersion": 1, + "vitePlusVersion": "1.2.0", + "vitestVersion": "5.0.5", + "globalOptions": [ + { + "flags": [ + "--global-option" + ], + "description": "global option", + "valueMode": "none" + } + ], + "commands": [ + { + "name": "", + "options": [] + }, + { + "name": "run", + "options": [] + } + ] +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-global/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-global/package.json new file mode 100644 index 0000000000..6afbba7713 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-global/package.json @@ -0,0 +1 @@ +{"name": "vite-plus", "version": "1.2.0"} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-global/vitest-package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-global/vitest-package.json new file mode 100644 index 0000000000..9ebdf56052 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-global/vitest-package.json @@ -0,0 +1 @@ +{"name": "vitest", "version": "5.0.5"} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-old/artifacts/bin.js b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-old/artifacts/bin.js new file mode 100644 index 0000000000..91233a7ecb --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-old/artifacts/bin.js @@ -0,0 +1 @@ +throw new Error('Completion must never execute JavaScript'); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-old/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-old/package.json new file mode 100644 index 0000000000..fc0a018cb5 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-old/package.json @@ -0,0 +1 @@ +{"name": "vite-plus", "version": "1.1.0"} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-old/vitest-package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-old/vitest-package.json new file mode 100644 index 0000000000..185fa678d6 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/package-old/vitest-package.json @@ -0,0 +1 @@ +{"name": "vitest", "version": "5.0.3"} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/snapshots.toml new file mode 100644 index 0000000000..04825dd673 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/snapshots.toml @@ -0,0 +1,87 @@ +[[case]] +name = "test_completion_follows_local_toolchain" +vp = "global" +seed-runtime = false +env = { VP_COMPLETE = "bash", _CLAP_COMPLETE_INDEX = "2" } +steps = [ + { argv = ["vpt", "mkdir", "-p", "node_modules"], snapshot = false }, + { argv = ["vpt", "cp", "-r", "package-alpha", "node_modules/vite-plus"], snapshot = false }, + { argv = ["vpt", "cp", "-r", "package-alpha/artifacts", "node_modules/vite-plus/dist"], snapshot = false }, + { argv = ["vpt", "mkdir", "-p", "node_modules/vite-plus/node_modules/vitest"], snapshot = false }, + { argv = ["vpt", "cp", "package-alpha/vitest-package.json", "node_modules/vite-plus/node_modules/vitest/package.json"], snapshot = false }, + { argv = ["vpt", "mkdir", "-p", "other/node_modules"], snapshot = false }, + { argv = ["vpt", "cp", "-r", "package-beta", "other/node_modules/vite-plus"], snapshot = false }, + { argv = ["vpt", "cp", "-r", "package-beta/artifacts", "other/node_modules/vite-plus/dist"], snapshot = false }, + { argv = ["vpt", "mkdir", "-p", "other/node_modules/vite-plus/node_modules/vitest"], snapshot = false }, + { argv = ["vpt", "cp", "package-beta/vitest-package.json", "other/node_modules/vite-plus/node_modules/vitest/package.json"], snapshot = false }, + { argv = ["vp", "--", "vp", "test", "--al"], envs = [["VP_GLOBAL_CLI_JS_SCRIPTS_DIR", "${workspace}/global/dist"]], comment = "Uses the runnable local CLI metadata without starting Node" }, + { argv = ["vp", "--", "vp", "-C", "other", "test", "--be"], envs = [["VP_GLOBAL_CLI_JS_SCRIPTS_DIR", "${workspace}/global/dist"], ["_CLAP_COMPLETE_INDEX", "4"]], comment = "Reads metadata from the directory selected inside the completion payload" }, + { argv = ["vp", "--", "vp", "-Cother", "test", "--be"], envs = [["VP_GLOBAL_CLI_JS_SCRIPTS_DIR", "${workspace}/global/dist"], ["_CLAP_COMPLETE_INDEX", "3"]] }, + { argv = ["vp", "--", "vp", "-C=other", "test", "--be"], envs = [["VP_GLOBAL_CLI_JS_SCRIPTS_DIR", "${workspace}/global/dist"], ["_CLAP_COMPLETE_INDEX", "3"]] }, +] + +[[case]] +name = "test_completion_old_local_does_not_use_global_metadata" +vp = "global" +seed-runtime = false +env = { VP_COMPLETE = "bash", _CLAP_COMPLETE_INDEX = "2" } +steps = [ + { argv = ["vpt", "mkdir", "-p", "node_modules"], snapshot = false }, + { argv = ["vpt", "cp", "-r", "package-old", "node_modules/vite-plus"], snapshot = false }, + { argv = ["vpt", "cp", "-r", "package-old/artifacts", "node_modules/vite-plus/dist"], snapshot = false }, + { argv = ["vpt", "cp", "-r", "package-global", "global"], snapshot = false }, + { argv = ["vpt", "cp", "-r", "package-global/artifacts", "global/dist"], snapshot = false }, + { argv = ["vpt", "mkdir", "-p", "global/node_modules/vitest"], snapshot = false }, + { argv = ["vpt", "cp", "package-global/vitest-package.json", "global/node_modules/vitest/package.json"], snapshot = false }, + { argv = ["vp", "--", "vp", "test", "--glob"], envs = [["VP_GLOBAL_CLI_JS_SCRIPTS_DIR", "${workspace}/global/dist"]], comment = "An old runnable local package must not use a newer global completion schema" }, + { argv = ["vpt", "rm", "node_modules/vite-plus/dist/bin.js"], snapshot = false }, + { argv = ["vp", "--", "vp", "test", "--glob"], envs = [["VP_GLOBAL_CLI_JS_SCRIPTS_DIR", "${workspace}/global/dist"]], comment = "Falls back globally only when the local CLI is not runnable" }, +] + +[[case]] +name = "test_completion_vitest_3_silently_declines" +vp = "global" +seed-runtime = false +env = { VP_COMPLETE = "bash", _CLAP_COMPLETE_INDEX = "2" } +steps = [ + { argv = ["vpt", "mkdir", "-p", "node_modules"], snapshot = false }, + { argv = ["vpt", "cp", "-r", "package-alpha", "node_modules/vite-plus"], snapshot = false }, + { argv = ["vpt", "cp", "-r", "package-alpha/artifacts", "node_modules/vite-plus/dist"], snapshot = false }, + { argv = ["vpt", "mkdir", "-p", "node_modules/vite-plus/node_modules/vitest"], snapshot = false }, + { argv = ["vpt", "write-file", "node_modules/vite-plus/node_modules/vitest/package.json", '{"name":"vitest","version":"3.2.4"}'], snapshot = false }, + { argv = ["vpt", "cp", "-r", "package-global", "global"], snapshot = false }, + { argv = ["vpt", "cp", "-r", "package-global/artifacts", "global/dist"], snapshot = false }, + { argv = ["vpt", "mkdir", "-p", "global/node_modules/vitest"], snapshot = false }, + { argv = ["vpt", "cp", "package-global/vitest-package.json", "global/node_modules/vitest/package.json"], snapshot = false }, + { argv = ["vp", "--", "vp", "test", "--al"], envs = [["VP_GLOBAL_CLI_JS_SCRIPTS_DIR", "${workspace}/global/dist"]], comment = "Stale Vitest 5 metadata with legacy Vitest: empty stdout/stderr and success" }, + { argv = ["vp", "--", "vp", "test", "--glob"], envs = [["VP_GLOBAL_CLI_JS_SCRIPTS_DIR", "${workspace}/global/dist"]], comment = "Does not borrow the global toolchain's Vitest 5 metadata" }, + { argv = ["vpt", "rm", "node_modules/vite-plus/dist/completion/test.json"], snapshot = false }, + { argv = ["vp", "--", "vp", "test", "--al"], envs = [["VP_GLOBAL_CLI_JS_SCRIPTS_DIR", "${workspace}/global/dist"]], comment = "Missing metadata also completes silently" }, + { argv = ["vpt", "cp", "package-alpha/artifacts/completion/test.json", "node_modules/vite-plus/dist/completion/test.json"], snapshot = false }, + { argv = ["vpt", "cp", "package-alpha/vitest-package.json", "node_modules/vite-plus/node_modules/vitest/package.json"], snapshot = false }, + { argv = ["vp", "--", "vp", "test", "--al"], comment = "Positive control: matched Vitest 5 metadata completes again, without executing tests" }, +] + +[[case]] +name = "test_completion_vitest_4_silently_declines" +vp = "global" +seed-runtime = false +env = { VP_COMPLETE = "bash", _CLAP_COMPLETE_INDEX = "2" } +steps = [ + { argv = ["vpt", "mkdir", "-p", "node_modules"], snapshot = false }, + { argv = ["vpt", "cp", "-r", "package-alpha", "node_modules/vite-plus"], snapshot = false }, + { argv = ["vpt", "cp", "-r", "package-alpha/artifacts", "node_modules/vite-plus/dist"], snapshot = false }, + { argv = ["vpt", "mkdir", "-p", "node_modules/vite-plus/node_modules/vitest"], snapshot = false }, + { argv = ["vpt", "write-file", "node_modules/vite-plus/node_modules/vitest/package.json", '{"name":"vitest","version":"4.1.10"}'], snapshot = false }, + { argv = ["vpt", "cp", "-r", "package-global", "global"], snapshot = false }, + { argv = ["vpt", "cp", "-r", "package-global/artifacts", "global/dist"], snapshot = false }, + { argv = ["vpt", "mkdir", "-p", "global/node_modules/vitest"], snapshot = false }, + { argv = ["vpt", "cp", "package-global/vitest-package.json", "global/node_modules/vitest/package.json"], snapshot = false }, + { argv = ["vp", "--", "vp", "test", "--al"], envs = [["VP_GLOBAL_CLI_JS_SCRIPTS_DIR", "${workspace}/global/dist"]], comment = "Stale Vitest 5 metadata with legacy Vitest: empty stdout/stderr and success" }, + { argv = ["vp", "--", "vp", "test", "--glob"], envs = [["VP_GLOBAL_CLI_JS_SCRIPTS_DIR", "${workspace}/global/dist"]], comment = "Does not borrow the global toolchain's Vitest 5 metadata" }, + { argv = ["vpt", "rm", "node_modules/vite-plus/dist/completion/test.json"], snapshot = false }, + { argv = ["vp", "--", "vp", "test", "--al"], envs = [["VP_GLOBAL_CLI_JS_SCRIPTS_DIR", "${workspace}/global/dist"]], comment = "Missing metadata also completes silently" }, + { argv = ["vpt", "cp", "package-alpha/artifacts/completion/test.json", "node_modules/vite-plus/dist/completion/test.json"], snapshot = false }, + { argv = ["vpt", "cp", "package-alpha/vitest-package.json", "node_modules/vite-plus/node_modules/vitest/package.json"], snapshot = false }, + { argv = ["vp", "--", "vp", "test", "--al"], comment = "Positive control: matched Vitest 5 metadata completes again, without executing tests" }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/snapshots/test_completion_follows_local_toolchain.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/snapshots/test_completion_follows_local_toolchain.md new file mode 100644 index 0000000000..c895597320 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/snapshots/test_completion_follows_local_toolchain.md @@ -0,0 +1,59 @@ +# test_completion_follows_local_toolchain + +## `vpt mkdir -p node_modules` + + +## `vpt cp -r package-alpha node_modules/vite-plus` + + +## `vpt cp -r package-alpha/artifacts node_modules/vite-plus/dist` + + +## `vpt mkdir -p node_modules/vite-plus/node_modules/vitest` + + +## `vpt cp package-alpha/vitest-package.json node_modules/vite-plus/node_modules/vitest/package.json` + + +## `vpt mkdir -p other/node_modules` + + +## `vpt cp -r package-beta other/node_modules/vite-plus` + + +## `vpt cp -r package-beta/artifacts other/node_modules/vite-plus/dist` + + +## `vpt mkdir -p other/node_modules/vite-plus/node_modules/vitest` + + +## `vpt cp package-beta/vitest-package.json other/node_modules/vite-plus/node_modules/vitest/package.json` + + +## `VP_GLOBAL_CLI_JS_SCRIPTS_DIR=${workspace}/global/dist vp -- vp test --al` + +Uses the runnable local CLI metadata without starting Node + +``` +--alpha +``` + +## `VP_GLOBAL_CLI_JS_SCRIPTS_DIR=${workspace}/global/dist _CLAP_COMPLETE_INDEX=4 vp -- vp -C other test --be` + +Reads metadata from the directory selected inside the completion payload + +``` +--beta +``` + +## `VP_GLOBAL_CLI_JS_SCRIPTS_DIR=${workspace}/global/dist _CLAP_COMPLETE_INDEX=3 vp -- vp -Cother test --be` + +``` +--beta +``` + +## `VP_GLOBAL_CLI_JS_SCRIPTS_DIR=${workspace}/global/dist _CLAP_COMPLETE_INDEX=3 vp -- vp -C=other test --be` + +``` +--beta +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/snapshots/test_completion_old_local_does_not_use_global_metadata.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/snapshots/test_completion_old_local_does_not_use_global_metadata.md new file mode 100644 index 0000000000..19fa1d822d --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/snapshots/test_completion_old_local_does_not_use_global_metadata.md @@ -0,0 +1,40 @@ +# test_completion_old_local_does_not_use_global_metadata + +## `vpt mkdir -p node_modules` + + +## `vpt cp -r package-old node_modules/vite-plus` + + +## `vpt cp -r package-old/artifacts node_modules/vite-plus/dist` + + +## `vpt cp -r package-global global` + + +## `vpt cp -r package-global/artifacts global/dist` + + +## `vpt mkdir -p global/node_modules/vitest` + + +## `vpt cp package-global/vitest-package.json global/node_modules/vitest/package.json` + + +## `VP_GLOBAL_CLI_JS_SCRIPTS_DIR=${workspace}/global/dist vp -- vp test --glob` + +An old runnable local package must not use a newer global completion schema + +``` +``` + +## `vpt rm node_modules/vite-plus/dist/bin.js` + + +## `VP_GLOBAL_CLI_JS_SCRIPTS_DIR=${workspace}/global/dist vp -- vp test --glob` + +Falls back globally only when the local CLI is not runnable + +``` +--global-option +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/snapshots/test_completion_vitest_3_silently_declines.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/snapshots/test_completion_vitest_3_silently_declines.md new file mode 100644 index 0000000000..a77be7b8b8 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/snapshots/test_completion_vitest_3_silently_declines.md @@ -0,0 +1,66 @@ +# test_completion_vitest_3_silently_declines + +## `vpt mkdir -p node_modules` + + +## `vpt cp -r package-alpha node_modules/vite-plus` + + +## `vpt cp -r package-alpha/artifacts node_modules/vite-plus/dist` + + +## `vpt mkdir -p node_modules/vite-plus/node_modules/vitest` + + +## `vpt write-file node_modules/vite-plus/node_modules/vitest/package.json '{"name":"vitest","version":"3.2.4"}'` + + +## `vpt cp -r package-global global` + + +## `vpt cp -r package-global/artifacts global/dist` + + +## `vpt mkdir -p global/node_modules/vitest` + + +## `vpt cp package-global/vitest-package.json global/node_modules/vitest/package.json` + + +## `VP_GLOBAL_CLI_JS_SCRIPTS_DIR=${workspace}/global/dist vp -- vp test --al` + +Stale Vitest 5 metadata with legacy Vitest: empty stdout/stderr and success + +``` +``` + +## `VP_GLOBAL_CLI_JS_SCRIPTS_DIR=${workspace}/global/dist vp -- vp test --glob` + +Does not borrow the global toolchain's Vitest 5 metadata + +``` +``` + +## `vpt rm node_modules/vite-plus/dist/completion/test.json` + + +## `VP_GLOBAL_CLI_JS_SCRIPTS_DIR=${workspace}/global/dist vp -- vp test --al` + +Missing metadata also completes silently + +``` +``` + +## `vpt cp package-alpha/artifacts/completion/test.json node_modules/vite-plus/dist/completion/test.json` + + +## `vpt cp package-alpha/vitest-package.json node_modules/vite-plus/node_modules/vitest/package.json` + + +## `vp -- vp test --al` + +Positive control: matched Vitest 5 metadata completes again, without executing tests + +``` +--alpha +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/snapshots/test_completion_vitest_4_silently_declines.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/snapshots/test_completion_vitest_4_silently_declines.md new file mode 100644 index 0000000000..7c68432e4c --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/snapshots/test_completion_vitest_4_silently_declines.md @@ -0,0 +1,66 @@ +# test_completion_vitest_4_silently_declines + +## `vpt mkdir -p node_modules` + + +## `vpt cp -r package-alpha node_modules/vite-plus` + + +## `vpt cp -r package-alpha/artifacts node_modules/vite-plus/dist` + + +## `vpt mkdir -p node_modules/vite-plus/node_modules/vitest` + + +## `vpt write-file node_modules/vite-plus/node_modules/vitest/package.json '{"name":"vitest","version":"4.1.10"}'` + + +## `vpt cp -r package-global global` + + +## `vpt cp -r package-global/artifacts global/dist` + + +## `vpt mkdir -p global/node_modules/vitest` + + +## `vpt cp package-global/vitest-package.json global/node_modules/vitest/package.json` + + +## `VP_GLOBAL_CLI_JS_SCRIPTS_DIR=${workspace}/global/dist vp -- vp test --al` + +Stale Vitest 5 metadata with legacy Vitest: empty stdout/stderr and success + +``` +``` + +## `VP_GLOBAL_CLI_JS_SCRIPTS_DIR=${workspace}/global/dist vp -- vp test --glob` + +Does not borrow the global toolchain's Vitest 5 metadata + +``` +``` + +## `vpt rm node_modules/vite-plus/dist/completion/test.json` + + +## `VP_GLOBAL_CLI_JS_SCRIPTS_DIR=${workspace}/global/dist vp -- vp test --al` + +Missing metadata also completes silently + +``` +``` + +## `vpt cp package-alpha/artifacts/completion/test.json node_modules/vite-plus/dist/completion/test.json` + + +## `vpt cp package-alpha/vitest-package.json node_modules/vite-plus/node_modules/vitest/package.json` + + +## `vp -- vp test --al` + +Positive control: matched Vitest 5 metadata completes again, without executing tests + +``` +--alpha +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/vite.config.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/vite.config.ts new file mode 100644 index 0000000000..2c03783c61 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_test_completion/vite.config.ts @@ -0,0 +1 @@ +throw new Error('Test completion must not load project configuration'); diff --git a/crates/vp_global_cli/Cargo.toml b/crates/vp_global_cli/Cargo.toml index a19cc16eb6..3e66d16c61 100644 --- a/crates/vp_global_cli/Cargo.toml +++ b/crates/vp_global_cli/Cargo.toml @@ -13,7 +13,7 @@ path = "src/main.rs" [dependencies] chrono = { workspace = true } -clap = { workspace = true, features = ["derive"] } +clap = { workspace = true, features = ["derive", "string"] } clap_complete = { workspace = true, features = ["unstable-dynamic"] } directories = { workspace = true } dialoguer = { workspace = true } diff --git a/crates/vp_global_cli/src/completion.rs b/crates/vp_global_cli/src/completion.rs new file mode 100644 index 0000000000..6787fb2050 --- /dev/null +++ b/crates/vp_global_cli/src/completion.rs @@ -0,0 +1,354 @@ +//! Read-only Vitest completion. This grammar is never used to execute commands. +#![warn(clippy::print_stdout, clippy::print_stderr)] + +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::Path, +}; + +use clap::{Arg, ArgAction, Command, ValueHint}; +use oxc_resolver::{ResolveOptions, Resolver}; +use serde::Deserialize; + +const SUBCOMMANDS: &[&str] = &["run", "watch", "dev", "related", "bench", "list", "doctor"]; +const MAX_METADATA_SIZE: u64 = 1024 * 1024; + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +enum ValueMode { + None, + Required, + Optional, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct OptionMetadata { + flags: Vec, + description: String, + value_mode: ValueMode, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CommandMetadata { + name: String, + options: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Metadata { + schema_version: u32, + vite_plus_version: String, + vitest_version: String, + global_options: Vec, + commands: Vec, +} + +/// Decode the existing CompleteEnv transport, preserving the cursor's empty word. +/// Bash/Zsh provide an index; Fish/PowerShell already pass words up to the cursor. +pub(crate) fn request_words(args: &[String], index: Option) -> Option> { + let separator = args.iter().position(|arg| arg == "--")?; + let mut words = args.get(separator + 1..)?.to_vec(); + if words.is_empty() { + return None; + } + if let Some(index) = index { + if index > words.len() { + return None; + } + if index == words.len() { + words.push(String::new()); + } + words.truncate(index + 1); + } + Some(words) +} + +/// Only the leading global -C grammar belongs to the launcher. +pub(crate) fn command_words(words: &[String]) -> Option<&[String]> { + let words = words.get(1..)?; + match words.first()?.as_str() { + "-C" => words.get(2..), + arg if arg.starts_with("-C") && arg.len() > 2 => words.get(1..), + _ => Some(words), + } +} + +/// `scripts_dir` must be selected using the execution path's local-first rule. +pub(crate) fn with_test_completion( + root: Command, + words: &[String], + cwd: &Path, + scripts_dir: &Path, +) -> Command { + let Some(args) = command_words(words).and_then(|words| words.strip_prefix(&["test".into()])) + else { + return root; + }; + let Some(metadata) = read_metadata(scripts_dir, cwd) else { + return root; + }; + let Some(command) = test_command(&metadata, args) else { + return root; + }; + root.mut_subcommand("test", |_| command) +} + +fn read_metadata(scripts_dir: &Path, cwd: &Path) -> Option { + // Node resolves dependency lookups from the real package location (not pnpm's link). + let scripts_dir = fs::canonicalize(scripts_dir).ok()?; + let path = scripts_dir.join("completion/test.json"); + if fs::metadata(&path).ok()?.len() > MAX_METADATA_SIZE { + return None; + } + let metadata: Metadata = serde_json::from_slice(&fs::read(path).ok()?).ok()?; + if metadata.schema_version != 1 || !valid_metadata(&metadata) { + return None; + } + let package = read_package(scripts_dir.parent()?.join("package.json").as_path())?; + if package["name"] != "vite-plus" || package["version"].as_str()? != metadata.vite_plus_version + { + return None; + } + // Mirror resolveBundled: require/node conditions, CLI dependency before cwd. + let resolver = Resolver::new(ResolveOptions { + condition_names: vec!["require".into(), "node".into()], + ..ResolveOptions::default() + }); + let resolved = resolver + .resolve(&scripts_dir, "vitest/package.json") + .or_else(|_| resolver.resolve(cwd, "vitest/package.json")) + .ok()?; + let vitest = read_package(resolved.path())?; + if vitest["name"] != "vitest" || vitest["version"].as_str()? != metadata.vitest_version { + return None; + } + Some(metadata) +} + +fn read_package(path: &Path) -> Option { + serde_json::from_slice(&fs::read(path).ok()?).ok() +} + +fn valid_flag(flag: &str) -> bool { + if let Some(name) = flag.strip_prefix("--") { + name.as_bytes().first().is_some_and(u8::is_ascii_alphanumeric) + && name.len() <= 256 + && name.bytes().all(|c| c.is_ascii_alphanumeric() || b"._-".contains(&c)) + } else { + flag.len() == 2 && flag.starts_with('-') && flag.as_bytes()[1].is_ascii_alphanumeric() + } +} + +fn valid_metadata(metadata: &Metadata) -> bool { + if metadata.commands.len() > 64 || metadata.global_options.len() > 2048 { + return false; + } + let mut names = BTreeSet::new(); + metadata.commands.iter().all(|command| { + command.name.len() <= 64 + && command.name.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'-') + && names.insert(&command.name) + && valid_options(&command.options) + }) && valid_options(&metadata.global_options) +} + +fn valid_options(options: &[OptionMetadata]) -> bool { + options.len() <= 2048 + && options.iter().all(|option| { + !option.flags.is_empty() + && option.flags.len() <= 16 + && option.flags.iter().any(|flag| flag.starts_with("--")) + && option.flags.iter().all(|flag| valid_flag(flag)) + && option.description.len() <= 16384 + }) +} + +fn merged_options<'a>( + metadata: &'a Metadata, + command: Option<&'a CommandMetadata>, +) -> Option> { + let local = command.map_or(&[][..], |command| command.options.as_slice()); + let mut merged = BTreeMap::new(); + for scope in [metadata.global_options.as_slice(), local] { + let mut primary_flags = BTreeSet::new(); + for option in scope { + let key = option.flags.iter().find(|flag| flag.starts_with("--"))?; + if !primary_flags.insert(key) { + return None; + } + if let Some(inherited) = merged.insert(key, option) { + // CAC combines declarations; a local declaration is not a + // universal override of inherited arity or aliases. + if inherited.flags != option.flags || inherited.value_mode != option.value_mode { + return None; + } + } + } + } + // Invalid/ambiguous declarations must never reach clap's assertion checks. + let mut flags = BTreeSet::new(); + let options: Vec<_> = merged.into_values().collect(); + if options.iter().any(|option| option.flags.iter().any(|flag| !flags.insert(flag))) { + return None; + } + Some(options) +} + +fn empty_test() -> Command { + Command::new("test") + .about("Run tests") + .disable_help_flag(true) + .disable_help_subcommand(true) + .arg( + Arg::new("filters") + .num_args(0..) + .allow_hyphen_values(true) + .value_hint(ValueHint::Other), + ) +} + +/// Mirror help.rs::should_prepend_vitest_run without executing the runner. +/// Explicit commands and watch/help/run flags keep Vitest's default dispatch. +fn implicit_run(args: &[String]) -> bool { + if args.first().is_some_and(|arg| { + SUBCOMMANDS.contains(&arg.as_str()) || matches!(arg.as_str(), "init" | "complete") + }) { + return false; + } + !args + .iter() + .take_while(|arg| arg.as_str() != "--") + .any(|arg| matches!(arg.as_str(), "-h" | "--help" | "-w" | "--watch" | "--run")) +} + +fn test_command(metadata: &Metadata, args: &[String]) -> Option { + let first = args.first().map(String::as_str); + if matches!(first, Some("init" | "complete")) { + return Some(empty_test()); + } + let selected = first.filter(|first| SUBCOMMANDS.contains(first)); + let scope = if selected.is_some() { + None + } else if implicit_run(args) { + // A missing run declaration cannot safely describe Vite+'s implicit run. + Some(metadata.commands.iter().find(|command| command.name == "run")?) + } else { + metadata.commands.iter().find(|command| command.name.is_empty()) + }; + let root_options = merged_options(metadata, scope)?; + let pending = if selected.is_none() { pending_optional(&root_options, args) } else { None }; + let mut root = option_command("test", &root_options, pending)?; + // A word such as `list` in `--project list` is a value, not a subcommand. + // Expose subcommands only in the first position or for the selected command. + let offer_commands = args.len() <= 1 && !first.is_some_and(|word| word.starts_with('-')); + for command in &metadata.commands { + if !SUBCOMMANDS.contains(&command.name.as_str()) { + continue; + } + if !offer_commands && selected != Some(command.name.as_str()) { + continue; + } + let options = merged_options(metadata, Some(command))?; + let pending = if selected == Some(command.name.as_str()) { + pending_optional(&options, &args[1..]) + } else { + None + }; + root = root.subcommand(option_command(&command.name, &options, pending)?); + } + Some(root) +} + +/// Track previous tokens only to protect the ambiguous optional-value empty word. +fn pending_optional<'a>(options: &[&'a OptionMetadata], args: &[String]) -> Option<&'a str> { + if args.last().is_none_or(|word| !word.is_empty()) { + return None; + } + let mut pending: Option<&OptionMetadata> = None; + for word in &args[..args.len() - 1] { + if let Some(option) = pending.take() { + if option.value_mode == ValueMode::Required || !word.starts_with('-') || word == "-" { + continue; + } + } + if word == "--" { + return None; + } + if word.starts_with("--") { + let (flag, inline) = + word.split_once('=').map_or((word.as_str(), false), |(flag, _)| (flag, true)); + if !inline { + pending = options.iter().copied().find(|option| { + option.flags.iter().any(|f| f == flag) && option.value_mode != ValueMode::None + }); + } + } else if word.starts_with('-') { + let mut shorts = word[1..].chars().peekable(); + while let Some(short) = shorts.next() { + let flag = format!("-{short}"); + if let Some(option) = + options.iter().copied().find(|option| option.flags.contains(&flag)) + { + if option.value_mode != ValueMode::None { + if shorts.peek().is_none() { + pending = Some(option); + } + break; + } + } + } + } + } + pending + .filter(|option| option.value_mode == ValueMode::Optional) + .and_then(|option| option.flags.iter().find(|flag| flag.starts_with("--"))) + .map(String::as_str) +} + +fn option_command( + name: &str, + options: &[&OptionMetadata], + pending: Option<&str>, +) -> Option { + let mut command = Command::new(name.to_owned()) + .disable_help_flag(true) + .disable_version_flag(true) + .disable_help_subcommand(true); + for option in options { + let longs: Vec<_> = + option.flags.iter().filter_map(|flag| flag.strip_prefix("--")).collect(); + let id = option.flags.iter().find(|flag| flag.starts_with("--"))?; + let mut arg = + Arg::new(id.clone()).long(longs[0].to_owned()).help(option.description.clone()); + for long in &longs[1..] { + arg = arg.visible_alias((*long).to_owned()); + } + for (index, short) in option.flags.iter().filter(|flag| !flag.starts_with("--")).enumerate() + { + let short = short.chars().nth(1)?; + arg = if index == 0 { arg.short(short) } else { arg.visible_short_alias(short) }; + } + arg = match option.value_mode { + ValueMode::None => arg.action(ArgAction::Count), + ValueMode::Required => { + arg.num_args(1).action(ArgAction::Append).value_hint(ValueHint::Other) + } + ValueMode::Optional => arg + .num_args(if pending == Some(id) { 1..=1 } else { 0..=1 }) + .action(ArgAction::Append) + .value_hint(ValueHint::Other), + }; + command = command.arg(arg); + } + Some(command.arg( + Arg::new("filters").num_args(0..).action(ArgAction::Append).value_hint(ValueHint::Other), + )) +} + +#[cfg(test)] +#[path = "completion/tests.rs"] +mod tests; diff --git a/crates/vp_global_cli/src/completion/tests.rs b/crates/vp_global_cli/src/completion/tests.rs new file mode 100644 index 0000000000..8425be90bf --- /dev/null +++ b/crates/vp_global_cli/src/completion/tests.rs @@ -0,0 +1,457 @@ +use serde_json::{Value, json}; +use tempfile::TempDir; + +use super::*; + +fn option(flags: &[&str], mode: &str) -> Value { + json!({"flags":flags,"valueMode":mode,"description":"Option description"}) +} + +fn metadata_value() -> Value { + json!({ + "schemaVersion":1,"vitePlusVersion":"1.2.0","vitestVersion":"5.0.5", + "globalOptions":[option(&["--coverage"],"none"),option(&["--coverage.reporter"],"required"), + option(&["-w","--watch"],"none"),option(&["--reporter"],"required"), + option(&["--silent"],"optional"),option(&["-u","--update"],"optional"), + option(&["-p","--project"],"required"), + {"flags":["--no-color"],"valueMode":"none","description":"Disable colors"}], + "commands":[ + {"name":"","options":[]}, + {"name":"run","options":[]}, + {"name":"watch","options":[]}, + {"name":"list","options":[option(&["--filesOnly"],"none"), + {"flags":["--reporter"],"valueMode":"required","description":"List override"}]}, + {"name":"init","options":[]}, + {"name":"complete","options":[]} + ] + }) +} + +fn metadata() -> Metadata { + serde_json::from_value(metadata_value()).unwrap() +} + +fn candidates(args: &[&str]) -> Vec { + let args: Vec<_> = args.iter().map(|arg| (*arg).to_owned()).collect(); + let command = test_command(&metadata(), &args[2..]).unwrap(); + let mut root = Command::new("vp").subcommand(command); + root.clone().debug_assert(); + clap_complete::engine::complete( + &mut root, + args.iter().map(Into::into).collect(), + args.len() - 1, + None, + ) + .unwrap() + .into_iter() + .map(|candidate| candidate.get_value().to_string_lossy().into_owned()) + .collect() +} + +#[test] +fn completes_options_after_filters_and_subcommands() { + for args in [ + vec!["vp", "test", "--cov"], + vec!["vp", "test", "file.test.ts", "--cov"], + vec!["vp", "test", "run", "--cov"], + vec!["vp", "test", "watch", "--cov"], + ] { + assert!(candidates(&args).contains(&"--coverage".into()), "{args:?}"); + } + assert_eq!(candidates(&["vp", "test", "--coverage.r"]), ["--coverage.reporter"]); + assert_eq!(candidates(&["vp", "test", "--no-c"]), ["--no-color"]); + assert!(candidates(&["vp", "test", "-"]).contains(&"-w".into())); +} + +#[test] +fn recognizes_values_and_the_option_terminator() { + for args in [ + vec!["vp", "test", "--reporter", ""], + vec!["vp", "test", "--reporter=ver"], + vec!["vp", "test", "--silent", ""], + vec!["vp", "test", "-u", ""], + vec!["vp", "test", "list", "--silent", ""], + vec!["vp", "test", "--", "--cov"], + vec!["vp", "test", "--project", "list", "--files"], + ] { + assert!(candidates(&args).is_empty(), "{args:?}"); + } + assert!(candidates(&["vp", "test", "--silent", "--cov"]).contains(&"--coverage".into())); + assert!( + candidates(&["vp", "test", "--silent", "true", "--cov"]).contains(&"--coverage".into()) + ); + assert!( + candidates(&["vp", "test", "--reporter", "--silent", ""]).iter().any(|s| s == "--coverage") + ); +} + +#[test] +fn offers_subcommands_only_as_the_first_argument() { + assert!(candidates(&["vp", "test", ""]).contains(&"list".into())); + assert_eq!(candidates(&["vp", "test", "li"]), ["list"]); + assert!(!candidates(&["vp", "test", "file.test.ts", ""]).contains(&"list".into())); + assert!(!candidates(&["vp", "test", "--silent=true", ""]).contains(&"list".into())); + assert!(candidates(&["vp", "test", "init", "--cov"]).is_empty()); + assert!(candidates(&["vp", "test", "complete", "--cov"]).is_empty()); + assert!(!candidates(&["vp", "test", ""]).contains(&"complete".into())); +} + +#[test] +fn merges_list_overrides_and_preserves_descriptions() { + assert_eq!(candidates(&["vp", "test", "list", "--files"]), ["--filesOnly"]); + assert!(candidates(&["vp", "test", "--files"]).is_empty()); + let command = test_command(&metadata(), &["list".into(), "--rep".into()]).unwrap(); + let mut root = Command::new("vp").subcommand(command); + let args = ["vp", "test", "list", "--rep"].map(Into::into).to_vec(); + let result = clap_complete::engine::complete(&mut root, args, 3, None).unwrap(); + assert_eq!(result[0].get_help().unwrap().to_string(), "List override"); +} + +#[test] +fn does_not_drop_repeatable_options_or_create_unregistered_negations() { + assert!( + candidates(&["vp", "test", "--reporter", "json", "--rep"]).contains(&"--reporter".into()) + ); + assert!(candidates(&["vp", "test", "--watch", "--wa"]).contains(&"--watch".into())); + assert!(candidates(&["vp", "test", "--no-w"]).is_empty()); +} + +#[test] +fn decodes_transport_and_cursor_without_reindexing() { + let args: Vec<_> = ["vp", "--", "vp", "test", "--cov", "later"].map(str::to_owned).to_vec(); + assert_eq!(request_words(&args, Some(2)).unwrap(), ["vp", "test", "--cov"]); + assert_eq!(request_words(&args, Some(4)).unwrap().last().unwrap(), ""); + assert!(request_words(&args, Some(20)).is_none()); + assert!(request_words(&["vp".into()], None).is_none()); + for words in [ + vec!["vp", "-C", "project", "test", ""], + vec!["vp", "-Cproject", "test", ""], + vec!["vp", "-C=project", "test", ""], + ] { + let words: Vec<_> = words.iter().map(|s| (*s).into()).collect(); + assert_eq!(command_words(&words).unwrap(), ["test", ""]); + } +} + +fn fixture() -> (TempDir, std::path::PathBuf) { + let temp = TempDir::new().unwrap(); + let package = temp.path().join("node_modules/vite-plus"); + fs::create_dir_all(package.join("dist/completion")).unwrap(); + fs::create_dir_all(package.join("node_modules/vitest")).unwrap(); + fs::write(package.join("package.json"), r#"{"name":"vite-plus","version":"1.2.0"}"#).unwrap(); + fs::write( + package.join("node_modules/vitest/package.json"), + r#"{"name":"vitest","version":"5.0.5"}"#, + ) + .unwrap(); + fs::write( + package.join("dist/completion/test.json"), + serde_json::to_vec(&metadata_value()).unwrap(), + ) + .unwrap(); + (temp, package.join("dist")) +} + +#[test] +fn checks_both_toolchain_versions_without_running_javascript() { + let (temp, dist) = fixture(); + assert!(read_metadata(&dist, temp.path()).is_some()); + fs::write( + dist.parent().unwrap().join("node_modules/vitest/package.json"), + r#"{"name":"vitest","version":"5.0.4"}"#, + ) + .unwrap(); + assert!(read_metadata(&dist, temp.path()).is_none()); + fs::write( + dist.parent().unwrap().join("node_modules/vitest/package.json"), + r#"{"name":"vitest","version":"5.0.5"}"#, + ) + .unwrap(); + fs::write( + dist.parent().unwrap().join("package.json"), + r#"{"name":"vite-plus","version":"1.1.0"}"#, + ) + .unwrap(); + assert!(read_metadata(&dist, temp.path()).is_none()); +} + +#[test] +fn reads_minimal_metadata_and_ignores_previous_display_fields() { + let (temp, dist) = fixture(); + let minimal = read_metadata(&dist, temp.path()).unwrap(); + let mut previous = metadata_value(); + for option in previous["globalOptions"].as_array_mut().unwrap() { + option["rawName"] = json!("Previous declaration"); + option["negated"] = json!(false); + } + for command in previous["commands"].as_array_mut().unwrap() { + command["rawName"] = json!("Previous command declaration"); + command["description"] = json!("Previous description"); + } + fs::write(dist.join("completion/test.json"), previous.to_string()).unwrap(); + let previous = read_metadata(&dist, temp.path()).unwrap(); + for data in [minimal, previous] { + let mut command = Command::new("vp").subcommand(test_command(&data, &[]).unwrap()); + command.clone().debug_assert(); + let args = ["vp", "test", "--no-c"].map(Into::into).to_vec(); + let result = clap_complete::engine::complete(&mut command, args, 2, None).unwrap(); + assert_eq!(result[0].get_value().to_string_lossy(), "--no-color"); + } +} + +#[test] +fn legacy_vitest_missing_or_stale_metadata_preserves_original_completion() { + for version in ["3.2.4", "4.1.10"] { + for missing in [false, true] { + let (temp, dist) = fixture(); + fs::write( + dist.parent().unwrap().join("node_modules/vitest/package.json"), + json!({"name":"vitest","version":version}).to_string(), + ) + .unwrap(); + if missing { + fs::remove_file(dist.join("completion/test.json")).unwrap(); + } + assert!(read_metadata(&dist, temp.path()).is_none()); + for args in [ + vec!["vp", "test", "--cov"], + vec!["vp", "test", "list", "--filesO"], + vec!["vp", "test", "--glob"], + ] { + let words: Vec = args.iter().map(|arg| (*arg).to_owned()).collect(); + let root = crate::cli::command_with_help(); + let original = root.clone(); + let adapted = with_test_completion(root, &words, temp.path(), &dist); + let values = |mut command: Command| { + clap_complete::engine::complete( + &mut command, + words.iter().map(Into::into).collect(), + words.len() - 1, + None, + ) + .unwrap() + .into_iter() + .map(|candidate| candidate.get_value().to_string_lossy().into_owned()) + .collect::>() + }; + assert_eq!(values(adapted), values(original), "{version}, missing={missing}"); + } + } + } +} + +#[test] +fn rejects_missing_corrupt_unknown_or_oversized_metadata() { + let (temp, dist) = fixture(); + let file = dist.join("completion/test.json"); + for content in ["{}".to_owned(), "not json".into(), " ".repeat(MAX_METADATA_SIZE as usize + 1)] + { + fs::write(&file, content).unwrap(); + assert!(read_metadata(&dist, temp.path()).is_none()); + } + let mut invalid = metadata_value(); + invalid["schemaVersion"] = json!(2); + fs::write(&file, invalid.to_string()).unwrap(); + assert!(read_metadata(&dist, temp.path()).is_none()); + fs::remove_file(file).unwrap(); + assert!(read_metadata(&dist, temp.path()).is_none()); +} + +#[test] +fn rejects_conflicting_or_invalid_flags_before_clap_build() { + let mut invalid = metadata_value(); + invalid["globalOptions"][0]["flags"] = json!(["--bad flag"]); + assert!(!valid_metadata(&serde_json::from_value(invalid).unwrap())); + let mut invalid = metadata_value(); + invalid["globalOptions"][0]["flags"] = json!(["-w", "--coverage"]); + let invalid: Metadata = serde_json::from_value(invalid).unwrap(); + assert!(test_command(&invalid, &[]).is_none()); +} + +#[test] +fn rejects_duplicate_primary_flags_and_incompatible_inherited_declarations() { + let mut invalid = metadata_value(); + let duplicate = invalid["globalOptions"][0].clone(); + invalid["globalOptions"].as_array_mut().unwrap().push(duplicate); + let invalid: Metadata = serde_json::from_value(invalid).unwrap(); + assert!(test_command(&invalid, &[]).is_none()); + + let mut invalid = metadata_value(); + invalid["commands"][3]["options"][1]["valueMode"] = json!("none"); + let invalid: Metadata = serde_json::from_value(invalid).unwrap(); + assert!(test_command(&invalid, &["list".into()]).is_none()); +} + +#[test] +fn loads_cli_dependency_before_project_vitest_and_can_use_project_fallback() { + let (temp, dist) = fixture(); + let project_vitest = temp.path().join("node_modules/vitest"); + fs::create_dir_all(&project_vitest).unwrap(); + for version in ["3.2.4", "4.1.10", "5.0.4"] { + fs::write( + project_vitest.join("package.json"), + json!({"name":"vitest","version":version}).to_string(), + ) + .unwrap(); + assert!(read_metadata(&dist, temp.path()).is_some()); + } + fs::remove_dir_all(dist.parent().unwrap().join("node_modules/vitest")).unwrap(); + assert!(read_metadata(&dist, temp.path()).is_none()); + fs::write(project_vitest.join("package.json"), r#"{"name":"vitest","version":"5.0.5"}"#) + .unwrap(); + assert!(read_metadata(&dist, temp.path()).is_some()); +} + +// This is Vite+'s mapping policy, tested with synthetic upstream definitions. +// Adding/removing upstream commands must not require an inventory snapshot update. +#[test] +fn maps_only_available_supported_commands_and_uses_selected_scope() { + let supported = ["run", "watch", "dev", "related", "bench", "list", "doctor"]; + let mut value = metadata_value(); + value["globalOptions"] = json!([option(&["--shared"], "none")]); + let mut commands = vec![json!({ + "name":"", + "options":[option(&["--default-only"], "none")] + })]; + for name in supported.into_iter().chain(["future-command"]) { + commands.push(json!({ + "name":name, + "options":[option(&["--local-only"], "none")] + })); + } + value["commands"] = json!(commands); + let data: Metadata = serde_json::from_value(value.clone()).unwrap(); + let root = test_command(&data, &[String::new()]).unwrap(); + let names: Vec<_> = root.get_subcommands().map(Command::get_name).collect(); + assert_eq!(names, supported); + assert!(root.get_arguments().any(|arg| arg.get_long() == Some("local-only"))); + assert!(!root.get_arguments().any(|arg| arg.get_long() == Some("default-only"))); + for name in supported { + let command = root.find_subcommand(name).unwrap(); + assert!(command.get_arguments().any(|arg| arg.get_long() == Some("shared"))); + assert!(command.get_arguments().any(|arg| arg.get_long() == Some("local-only"))); + assert!(!command.get_arguments().any(|arg| arg.get_long() == Some("default-only"))); + } + // Upstream may omit a currently supported command; never manufacture it. + value["commands"] = json!([]); + let data: Metadata = serde_json::from_value(value).unwrap(); + assert!(test_command(&data, &[String::new()]).is_none()); +} + +#[test] +fn invalid_metadata_preserves_nonempty_original_completion() { + let (temp, dist) = fixture(); + let file = dist.join("completion/test.json"); + let mut unknown_schema = metadata_value(); + unknown_schema["schemaVersion"] = json!(2); + let mut conflict = metadata_value(); + let duplicate = conflict["globalOptions"][0].clone(); + conflict["globalOptions"].as_array_mut().unwrap().push(duplicate); + let mut mismatch = metadata_value(); + mismatch["vitestVersion"] = json!("0.0.0"); + for content in [ + None, + Some("not json".to_owned()), + Some(unknown_schema.to_string()), + Some(conflict.to_string()), + Some(mismatch.to_string()), + Some(" ".repeat(MAX_METADATA_SIZE as usize + 1)), + ] { + if let Some(content) = content { + fs::write(&file, content).unwrap(); + } else { + fs::remove_file(&file).unwrap(); + } + let words: Vec = ["vp", "test", "--base"].map(str::to_owned).to_vec(); + let root = crate::cli::command_with_help().mut_subcommand("test", |command| { + command.arg(Arg::new("baseline").long("baseline").action(ArgAction::SetTrue)) + }); + let adapted = with_test_completion(root.clone(), &words, temp.path(), &dist); + let values = |mut command: Command| { + clap_complete::engine::complete( + &mut command, + words.iter().map(Into::into).collect(), + 2, + None, + ) + .unwrap() + .into_iter() + .map(|candidate| candidate.get_value().to_string_lossy().into_owned()) + .collect::>() + }; + let original = values(root); + assert!(original.contains(&"--baseline".to_owned())); + assert_eq!(values(adapted), original); + } +} + +#[test] +fn implicit_run_scope_follows_vite_plus_dispatch_exceptions() { + let mut value = metadata_value(); + value["commands"][0]["options"] = json!([option(&["--default-only"], "none")]); + value["commands"][1]["options"] = json!([option(&["--run-only"], "none")]); + let data: Metadata = serde_json::from_value(value).unwrap(); + for (args, prepend) in [ + (vec![], true), + (vec![""], true), + (vec!["filter.test.ts", "--ru"], true), + (vec!["--watch"], false), + (vec!["-w"], false), + (vec!["--help"], false), + (vec!["-h"], false), + (vec!["--run"], false), + (vec!["--", "--watch"], true), + (vec!["--watch=false"], true), + (vec!["--run=false"], true), + (vec!["--project", "watch"], true), + ] { + let args: Vec = args.iter().map(|arg| (*arg).into()).collect(); + assert_eq!(implicit_run(&args), prepend, "{args:?}"); + let root = test_command(&data, &args).unwrap(); + let expected = if prepend { "run-only" } else { "default-only" }; + let excluded = if prepend { "default-only" } else { "run-only" }; + assert!(root.get_arguments().any(|arg| arg.get_long() == Some(expected)), "{args:?}"); + assert!(!root.get_arguments().any(|arg| arg.get_long() == Some(excluded)), "{args:?}"); + } + for name in SUBCOMMANDS.iter().copied().chain(["init", "complete"]) { + assert!(!implicit_run(&[name.into()])); + } + let command = test_command(&data, &["run".into(), "--ru".into()]).unwrap(); + assert!( + command + .find_subcommand("run") + .unwrap() + .get_arguments() + .any(|arg| arg.get_long() == Some("run-only")) + ); +} + +#[test] +fn rejects_schema_boundary_violations() { + for field in ["flags", "description", "valueMode"] { + let mut missing = metadata_value(); + missing["globalOptions"][0].as_object_mut().unwrap().remove(field); + assert!(serde_json::from_value::(missing).is_err(), "{field}"); + } + let mut invalid_arity = metadata_value(); + invalid_arity["globalOptions"][0]["valueMode"] = json!("unsupported"); + assert!(serde_json::from_value::(invalid_arity).is_err()); + + for (pointer, invalid) in [ + ("/commands/1/name", json!("bad/name")), + ("/commands/1/name", json!("x".repeat(65))), + ("/globalOptions/0/flags", json!(["-ab", "--coverage"])), + ("/globalOptions/0/flags", json!([format!("--{}", "x".repeat(257))])), + ("/globalOptions/0/description", json!("文".repeat(5462))), + ] { + let mut value = metadata_value(); + *value.pointer_mut(pointer).unwrap() = invalid; + assert!(!valid_metadata(&serde_json::from_value(value).unwrap()), "{pointer}"); + } + let mut duplicate = metadata_value(); + duplicate["commands"][2]["name"] = json!("run"); + assert!(!valid_metadata(&serde_json::from_value(duplicate).unwrap())); + let mut excessive = metadata_value(); + excessive["globalOptions"] = json!(vec![option(&["--synthetic"], "none"); 2049]); + assert!(!valid_metadata(&serde_json::from_value(excessive).unwrap())); +} diff --git a/crates/vp_global_cli/src/js_executor.rs b/crates/vp_global_cli/src/js_executor.rs index eaf698667b..3199d4ca68 100644 --- a/crates/vp_global_cli/src/js_executor.rs +++ b/crates/vp_global_cli/src/js_executor.rs @@ -407,6 +407,21 @@ impl JsExecutor { Ok(output) } + /// Select the same scripts directory for execution and read-only completion. + /// A local CLI without metadata must not use another toolchain's metadata. + pub(crate) fn resolve_project_scripts_dir( + &self, + project_path: &AbsolutePath, + ) -> Result { + if let Some(entry) = Self::resolve_local_vite_plus(project_path) { + return entry + .parent() + .map(AbsolutePath::to_absolute_path_buf) + .ok_or(Error::JsScriptsDirNotFound); + } + self.get_scripts_dir() + } + /// Resolve the local vite-plus package root from the project directory. pub(crate) fn resolve_local_vite_plus_package_dir( project_path: &AbsolutePath, diff --git a/crates/vp_global_cli/src/main.rs b/crates/vp_global_cli/src/main.rs index 5259962b08..7e51c30eeb 100644 --- a/crates/vp_global_cli/src/main.rs +++ b/crates/vp_global_cli/src/main.rs @@ -15,6 +15,7 @@ mod cli; mod command_picker; mod commands; +mod completion; mod error; mod help; mod homebrew; @@ -334,13 +335,10 @@ async fn main() -> ExitCode { return ExitCode::SUCCESS; } - match self_setup::maybe_run().await { - Ok(Some(code)) => return code, - Ok(None) => {} - Err(error) => { - output::error(&error.to_string()); - return ExitCode::FAILURE; - } + if env::var_os("VP_COMPLETE").is_some_and(|shell| !shell.is_empty() && shell != "0") + && let Some(code) = self_setup::complete_from_existing_install().await + { + return code; } let mut args: Vec = std::env::args().collect(); @@ -358,14 +356,53 @@ async fn main() -> ExitCode { // `complete()` exits before the normal `-C` handling below runs, so apply // the target directory here too: cwd-sensitive completers (run tasks) // should suggest from , where the completed command will run. + let completion_words = completion::request_words( + &args, + env::var("_CLAP_COMPLETE_INDEX").ok().and_then(|index| index.parse().ok()), + ); + let mut completion_directory_valid = true; if env::var_os("VP_COMPLETE").is_some() - && let Some((dir, _)) = parse_leading_chdir(&args[1..]) + && let Some(words) = &completion_words + && let Some((dir, _)) = parse_leading_chdir(&words[1..]) && let Ok(current) = vt_path::current_dir() { // Best-effort and silent: mid-typing values are often not (yet) dirs. - let _ = apply_chdir(¤t, &dir); + completion_directory_valid = apply_chdir(¤t, &dir).is_ok(); + } + CompleteEnv::with_factory(|| { + let root = command_with_help(); + if !completion_directory_valid { + return root; + } + let Some(words) = &completion_words else { + return root; + }; + if completion::command_words(words).and_then(|words| words.first()).map(String::as_str) + != Some("test") + { + return root; + } + let Ok(cwd) = vt_path::current_dir() else { + return root; + }; + let Ok(scripts) = js_executor::JsExecutor::new(None).resolve_project_scripts_dir(&cwd) + else { + return root; + }; + completion::with_test_completion(root, words, cwd.as_path(), scripts.as_path()) + }) + .var("VP_COMPLETE") + .complete(); + + // Completion requests must not trigger first-start installation or execute JavaScript. + match self_setup::maybe_run().await { + Ok(Some(code)) => return code, + Ok(None) => {} + Err(error) => { + output::error(&error.to_string()); + return ExitCode::FAILURE; + } } - CompleteEnv::with_factory(command_with_help).var("VP_COMPLETE").complete(); // Check for shim mode (invoked as node, npm, or npx) let argv0 = args.first().map(|s| s.as_str()).unwrap_or("vp"); diff --git a/crates/vp_global_cli/src/self_setup.rs b/crates/vp_global_cli/src/self_setup.rs index b220b5d88b..58722802fc 100644 --- a/crates/vp_global_cli/src/self_setup.rs +++ b/crates/vp_global_cli/src/self_setup.rs @@ -17,6 +17,29 @@ use crate::{ error::Error, }; +/// Reuse a completed external installation without ever starting or repairing setup. +/// Missing, stale, unreadable or malformed receipts leave completion on its quiet fallback. +pub(crate) async fn complete_from_existing_install() -> Option { + let binary = std::env::current_exe().ok()?; + #[cfg(target_os = "macos")] + let binary = std::fs::canonicalize(binary).ok()?; + let bin = binary.parent()?; + if bin.join(SELF_SETUP_MARKER).try_exists().ok()? { + return None; + } + let data = normalize_target(&EnvConfig::get().dirs.data).ok()?; + if dunce::simplified(&binary).starts_with(dunce::simplified(data.as_path())) { + return None; + } + let state = external::SetupState::new(&binary, local_install_version().as_deref()).ok()?; + let installed = state.completed_binary(has_bundled_package(&binary)).await.ok()??; + let real_installed = std::fs::canonicalize(&installed).ok()?; + if real_installed == binary || !real_installed.parent()?.join(SELF_SETUP_MARKER).is_file() { + return None; + } + execute_installed(&installed, false).ok().flatten() +} + pub(crate) async fn maybe_run() -> Result, Error> { let shell = std::env::var(env_vars::VP_SELF_SETUP_SHELL).ok(); if let Some(shell) = shell.as_deref() { diff --git a/docs/guide/env.md b/docs/guide/env.md index f7ef2eb8e6..99d7239d55 100644 --- a/docs/guide/env.md +++ b/docs/guide/env.md @@ -180,6 +180,19 @@ In CI, `vp env use` can run without shell initialization. It writes a temporary #### Shell completion +In Bash, Zsh, Fish, and PowerShell, `vp test` also completes Vitest option names, +short options, and nested options such +as `--coverage.reporter`. Supported test subcommands include `run`, `watch`, `dev`, +`related`, `bench`, `list`, and `doctor`. Descriptions appear where supported by +your shell. Option values, test names, and project names are not completed. + +Test completion follows the selected project-local Vite+ toolchain, falling back +to the global toolchain only when no runnable local CLI is installed. Both the +global launcher and the selected Vite+ package must support this feature. Missing +or mismatched completion metadata silently falls back to the original completion +behavior without the additional Vitest option candidates. Pressing Tab does not +install dependencies or load project configuration. + The global CLI supports Tab completion in Bash, Zsh, Fish, Nushell, and PowerShell. Run `vp env setup` and follow its printed shell setup instructions to enable it. Press Tab to complete commands, or project task names after `vp run` and `vpr`. - **Zsh**: Run `autoload -Uz compinit; compinit` before loading the setup script if your shell has not initialized completion. diff --git a/package.json b/package.json index fd4ce34eb3..754bd5cf5b 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "docs:dev": "pnpm -C docs dev", "docs:build": "pnpm -C docs build", "docs:update-trusted-stack-stats": "pnpm -C docs update-trusted-stack-stats", - "prepare": "vp config" + "prepare": "vp config", + "test:completion": "node packages/tools/src/test-completion.ts" }, "devDependencies": { "@e18e/eslint-plugin": "catalog:", diff --git a/packages/cli/build.ts b/packages/cli/build.ts index 54eedabbc7..bfcc56264f 100644 --- a/packages/cli/build.ts +++ b/packages/cli/build.ts @@ -31,6 +31,7 @@ import { format } from 'oxfmt'; import { generateLicenseFile } from '../../scripts/generate-license.js'; import corePkg from '../core/package.json' with { type: 'json' }; +import { writeTestCompletionMetadata } from './build/completion-metadata.ts'; import { VITEST_VERSION } from './src/utils/constants.ts'; const projectDir = dirname(fileURLToPath(import.meta.url)); @@ -136,6 +137,7 @@ if (!skipTs) { await syncCorePackageExports(); await syncTestPackageExports(); await syncToolchainExports(); + await writeTestCompletionMetadata(projectDir, VITEST_VERSION); } await copyBundledDocs(); await syncReadmeFromRoot(); diff --git a/packages/cli/build/completion-metadata.ts b/packages/cli/build/completion-metadata.ts new file mode 100644 index 0000000000..78bbee6cd9 --- /dev/null +++ b/packages/cli/build/completion-metadata.ts @@ -0,0 +1,163 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +type CLI = ReturnType<(typeof import('vitest/node'))['createCLI']>; + +export interface OptionMetadata { + flags: string[]; + description: string; + valueMode: 'none' | 'required' | 'optional'; +} + +export interface TestCompletionMetadata { + schemaVersion: 1; + vitePlusVersion: string; + vitestVersion: string; + globalOptions: OptionMetadata[]; + commands: { + name: string; + options: OptionMetadata[]; + }[]; +} + +/** Keep CLI spelling: CAC normalizes `--no-color` to the name `color`. */ +export function extractCompletionMetadata( + cli: Pick, + vitePlusVersion: string, + vitestVersion: string, +): TestCompletionMetadata { + function option(value: CLI['globalCommand']['options'][number]): OptionMetadata { + const modes = [value.isBoolean === true, value.required === true, value.required === false]; + if (modes.filter(Boolean).length !== 1) { + throw new Error(`Unsupported Vitest option arity: ${value.rawName}`); + } + // Match only flag declarations. Removing bracketed placeholders would break + // nested syntax such as `--inspect [[host:]port]`. + const flags = Array.from( + value.rawName.matchAll(/(?:^|,\s*)(--?[^\s,]+)/g), + (match) => match[1], + ); + if ( + !flags.some((flag) => flag.startsWith('--')) || + flags.length > 16 || + flags.some((flag) => + flag.startsWith('--') + ? flag.length - 2 > 256 || !/^--[A-Za-z0-9][A-Za-z0-9._-]*$/.test(flag) + : !/^-[A-Za-z0-9]$/.test(flag), + ) || + Buffer.byteLength(value.description ?? '') > 16384 + ) { + throw new Error(`Unsupported Vitest option declaration: ${value.rawName}`); + } + return { + flags, + description: value.description ?? '', + valueMode: value.isBoolean ? 'none' : value.required ? 'required' : 'optional', + }; + } + + const metadata: TestCompletionMetadata = { + schemaVersion: 1, + vitePlusVersion, + vitestVersion, + globalOptions: cli.globalCommand.options.map(option), + commands: cli.commands.map((command) => ({ + name: command.name, + options: command.options.map(option), + })), + }; + if (metadata.commands.length > 64 || metadata.globalOptions.length > 2048) { + throw new Error('Vitest completion metadata exceeds schema limits'); + } + const names = new Set(); + for (const command of metadata.commands) { + if ( + !/^[A-Za-z0-9-]*$/.test(command.name) || + command.name.length > 64 || + names.has(command.name) || + command.options.length > 2048 + ) { + throw new Error(`Unsupported Vitest command declaration: ${command.name}`); + } + names.add(command.name); + } + const globals = new Map(); + for (const scope of [ + metadata.globalOptions, + ...metadata.commands.map((command) => command.options), + ]) { + const flags = new Set(); + for (const option of scope) { + for (const flag of option.flags) { + if (flags.has(flag)) { + throw new Error(`Duplicate Vitest option flag: ${flag}`); + } + flags.add(flag); + } + const primary = option.flags.find((flag) => flag.startsWith('--'))!; + if (scope === metadata.globalOptions) { + globals.set(primary, option); + } else { + const inherited = globals.get(primary); + if ( + inherited && + (inherited.flags.join(',') !== option.flags.join(',') || + inherited.valueMode !== option.valueMode) + ) { + throw new Error(`Incompatible inherited Vitest option: ${primary}`); + } + } + } + } + for (const command of metadata.commands) { + const merged = new Map(globals); + for (const option of command.options) { + merged.set( + option.flags.find((flag) => flag.startsWith('--'))!, + option, + ); + } + const flags = new Set(); + for (const option of merged.values()) { + for (const flag of option.flags) { + if (flags.has(flag)) { + throw new Error(`Conflicting inherited Vitest option flag: ${flag}`); + } + flags.add(flag); + } + } + } + if (Buffer.byteLength(JSON.stringify(metadata, null, 2) + '\n') > 1024 * 1024) { + throw new Error('Vitest completion metadata exceeds schema limits'); + } + return metadata; +} + +/** Generate after tsdown and shim synchronization have finished cleaning dist. */ +export async function writeTestCompletionMetadata( + packageDir: string, + expectedVitestVersion: string, +): Promise { + const require = createRequire(join(packageDir, 'package.json')); + const packageJson = JSON.parse(await readFile(join(packageDir, 'package.json'), 'utf8')); + const vitestPackage = JSON.parse(await readFile(require.resolve('vitest/package.json'), 'utf8')); + if (vitestPackage.version !== expectedVitestVersion) { + throw new Error(`Expected vitest@${expectedVitestVersion}, found ${vitestPackage.version}`); + } + // Resolve from the same CLI package that owns the runner dependency. No deep + // imports, CLI parsing, command actions, or project configuration loading. + const { createCLI } = (await import(pathToFileURL(require.resolve('vitest/node')).href)) as { + createCLI: () => CLI; + }; + const metadata = extractCompletionMetadata( + createCLI(), + packageJson.version, + vitestPackage.version, + ); + const directory = join(packageDir, 'dist/completion'); + await mkdir(directory, { recursive: true }); + await writeFile(join(directory, 'test.json'), `${JSON.stringify(metadata, null, 2)}\n`); + return metadata; +} diff --git a/packages/cli/src/__tests__/completion-metadata-consumer.spec.ts b/packages/cli/src/__tests__/completion-metadata-consumer.spec.ts new file mode 100644 index 0000000000..a92bdf8f48 --- /dev/null +++ b/packages/cli/src/__tests__/completion-metadata-consumer.spec.ts @@ -0,0 +1,143 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it } from 'vite-plus/test'; + +import { + cargoCompletionConsumer, + resolveCompletionConsumer, +} from '../../../tools/src/completion-consumer.ts'; + +const temporary: string[] = []; +afterEach(() => { + for (const directory of temporary.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +function workspace() { + const directory = mkdtempSync(path.join(os.tmpdir(), 'vp-consumer-selection-')); + temporary.push(directory); + return directory; +} + +function artifact(root: string, relative: string, time = 100) { + const name = process.platform === 'win32' ? 'vp.exe' : 'vp'; + const file = path.join(root, relative, name); + mkdirSync(path.dirname(file), { recursive: true }); + // Path fixtures exercise handoff only; integration tests execute the real artifact. + writeFileSync(file, 'Completion consumer path fixture'); + chmodSync(file, 0o755); + utimesSync(file, time, time); + return file; +} + +function message(executable: string | null, extra = {}) { + return JSON.stringify({ + reason: 'compiler-artifact', + target: { name: 'vp', kind: ['bin'] }, + executable, + ...extra, + }); +} + +describe('completion Rust consumer handoff', () => { + it('requires build provenance even when default artifacts and PATH entries exist', () => { + const root = workspace(); + artifact(root, 'target/debug'); + expect(() => resolveCompletionConsumer(root, { PATH: root })).toThrow( + 'Missing VP_COMPLETION_CONSUMER_BIN', + ); + }); + + it('uses the supplied artifact despite newer old builds, other targets and PATH shadows', () => { + const root = workspace(); + const supplied = artifact(root, 'custom/host/ci', 100); + artifact(root, 'target/release', 900); + artifact(root, 'target/other-triple/debug', 1000); + const shadow = path.join(root, 'node_modules/.bin'); + mkdirSync(shadow, { recursive: true }); + writeFileSync(path.join(shadow, 'vp'), '#!/usr/bin/env node\nthrow new Error("Wrong entry");'); + expect( + resolveCompletionConsumer(root, { + VP_COMPLETION_CONSUMER_BIN: supplied, + PATH: shadow, + CARGO_TARGET_DIR: 'target', + CARGO_BUILD_TARGET: 'other-triple', + }), + ).toBe(supplied); + }); + + it('does not inspect newer invalid foreign artifacts', () => { + const root = workspace(); + const supplied = artifact(root, 'target/debug', 100); + const foreign = artifact(root, 'target/foreign/release', 200); + writeFileSync(foreign, '#!/bin/sh\nexit 93\n'); + expect(resolveCompletionConsumer(root, { VP_COMPLETION_CONSUMER_BIN: supplied })).toBe( + supplied, + ); + }); + + it.each(['target/debug', 'target/host-triple/release', 'custom build/host-triple/ci'])( + 'accepts an exact build/restored path in %s without directory discovery', + (layout) => { + const root = workspace(); + const binary = artifact(root, layout); + expect( + resolveCompletionConsumer(root, { + VP_COMPLETION_CONSUMER_BIN: path.relative(root, binary), + }), + ).toBe(binary); + expect(resolveCompletionConsumer(root, { VP_COMPLETION_CONSUMER_BIN: binary })).toBe(binary); + }, + ); + + it('fails on a missing file or directory without borrowing a cached binary', () => { + const root = workspace(); + artifact(root, 'target/debug'); + expect(() => + resolveCompletionConsumer(root, { VP_COMPLETION_CONSUMER_BIN: 'missing' }), + ).toThrow('does not exist'); + expect(() => resolveCompletionConsumer(root, { VP_COMPLETION_CONSUMER_BIN: root })).toThrow( + 'does not exist', + ); + }); + + it.skipIf(process.platform === 'win32')( + 'rejects a supplied binary without execute permission', + () => { + const root = workspace(); + const binary = artifact(root, 'target/debug'); + chmodSync(binary, 0o644); + expect(() => resolveCompletionConsumer(root, { VP_COMPLETION_CONSUMER_BIN: binary })).toThrow( + 'must be executable', + ); + }, + ); + + it('forwards the executable reported by Cargo including configured output dirs and fresh cached builds', () => { + const root = workspace(); + const executable = path.join(root, 'configured output/host/custom-profile/vp'); + const output = + [ + JSON.stringify({ reason: 'compiler-message' }), + message(null), + message('/irrelevant', { target: { name: 'vp-shim', kind: ['bin'] } }), + message(executable, { fresh: true }), + JSON.stringify({ reason: 'build-finished', success: true }), + ].join('\n') + '\n'; + expect(cargoCompletionConsumer(output, root)).toBe(executable); + expect(cargoCompletionConsumer(message(executable, { fresh: false }), root)).toBe(executable); + }); + + it('rejects missing, ambiguous or malformed Cargo artifact output', () => { + const root = workspace(); + expect(() => cargoCompletionConsumer('', root)).toThrow('received 0'); + expect(() => cargoCompletionConsumer(message(null), root)).toThrow('received 0'); + expect(() => + cargoCompletionConsumer([message('/first/vp'), message('/second/vp')].join('\n'), root), + ).toThrow('received 2'); + expect(() => cargoCompletionConsumer('invalid json', root)).toThrow(); + }); +}); diff --git a/packages/cli/src/__tests__/completion-metadata-contract.spec.ts b/packages/cli/src/__tests__/completion-metadata-contract.spec.ts new file mode 100644 index 0000000000..2607039b80 --- /dev/null +++ b/packages/cli/src/__tests__/completion-metadata-contract.spec.ts @@ -0,0 +1,107 @@ +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; + +import { describe, expect, it } from 'vite-plus/test'; +import { createCLI } from 'vitest/node'; + +import { extractCompletionMetadata } from '../../build/completion-metadata.ts'; +import { VITEST_VERSION } from '../utils/constants.ts'; + +type CLI = ReturnType; + +function metadata(cli = createCLI()) { + return extractCompletionMetadata(cli, '1.3.0', VITEST_VERSION); +} + +// These checks exercise the public surface consumed by the adapter. Command +// names, counts, aliases, descriptions and parser transforms are not frozen. +describe('public createCLI metadata contract (installed Vitest)', () => { + it('projects the public return type deterministically without a fixed inventory', () => { + const cli: CLI = createCLI(); + const data = metadata(cli); + expect(data.commands.map((command) => command.name)).toEqual( + cli.commands.map((command) => command.name), + ); + const sourceScopes = [cli.globalCommand, ...cli.commands]; + const targetScopes = [data.globalOptions, ...data.commands.map((command) => command.options)]; + for (const [index, source] of sourceScopes.entries()) { + expect(targetScopes[index]).toHaveLength(source.options.length); + for (const [optionIndex, option] of source.options.entries()) { + const projected = targetScopes[index][optionIndex]; + expect(projected).toEqual({ + flags: expect.any(Array), + description: option.description ?? '', + valueMode: option.isBoolean ? 'none' : option.required ? 'required' : 'optional', + }); + for (const flag of projected.flags) { + expect(option.rawName).toContain(flag); + } + } + } + for (const command of data.commands) { + expect(Object.keys(command).toSorted()).toEqual(['name', 'options']); + } + expect(JSON.parse(JSON.stringify(data))).toEqual(data); + expect(metadata()).toEqual(data); + }); + + it('is read-only and never calls parse or command actions during extraction', () => { + const cli = createCLI(); + const before = metadata(cli); + cli.parse = () => { + throw new Error('Must not parse during metadata extraction'); + }; + for (const command of cli.commands) { + command.action(() => { + throw new Error('Must not run actions during metadata extraction'); + }); + } + Object.freeze(cli.globalCommand.options); + for (const command of [cli.globalCommand, ...cli.commands]) { + for (const option of command.options) { + Object.freeze(option); + } + Object.freeze(command.options); + Object.freeze(command); + } + Object.freeze(cli.commands); + expect(metadata(cli)).toEqual(before); + }); + + it('constructs without parsing argv, loading project config or running actions', () => { + const require = createRequire(import.meta.url); + const entry = require.resolve('vitest/node'); + const script = ` + import { pathToFileURL } from 'node:url'; + const {createCLI} = await import(pathToFileURL(${JSON.stringify(entry)})); + // If construction accidentally starts CLI parsing, help produces output. + process.argv = ['node', 'vitest', 'run', '--help']; + createCLI(); + await new Promise(resolve => setImmediate(resolve)); + `; + const directory = mkdtempSync(path.join(os.tmpdir(), 'vp-createcli-contract-')); + try { + for (const name of ['vite.config.ts', 'vitest.config.ts']) { + writeFileSync( + path.join(directory, name), + "throw new Error('Must not load project config');", + ); + } + const result = spawnSync(process.execPath, ['--input-type=module', '-e', script], { + cwd: directory, + encoding: 'utf8', + timeout: 15_000, + stdio: ['ignore', 'pipe', 'pipe'], + }); + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toBe(''); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/__tests__/completion-metadata.spec.ts b/packages/cli/src/__tests__/completion-metadata.spec.ts new file mode 100644 index 0000000000..779c3ac316 --- /dev/null +++ b/packages/cli/src/__tests__/completion-metadata.spec.ts @@ -0,0 +1,203 @@ +import { mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it } from 'vite-plus/test'; +import { createCLI } from 'vitest/node'; + +import { + extractCompletionMetadata, + writeTestCompletionMetadata, +} from '../../build/completion-metadata.ts'; +import { VITEST_VERSION } from '../utils/constants.ts'; + +const temporary: string[] = []; +afterEach(async () => { + await Promise.all(temporary.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +// Use the public CAC builder with controlled declarations. None of the flags +// below asserts that a particular Vitest release must ship that option. +function fixture() { + const cli = createCLI(); + cli.commands.splice(0); + cli.globalCommand.options.splice(0); + return cli; +} + +function extract(cli = fixture()) { + return extractCompletionMetadata(cli, '1.3.0', VITEST_VERSION); +} + +describe('Vitest completion metadata adapter (synthetic declarations)', () => { + it('preserves boolean, required and optional declarations and short aliases', () => { + const cli = fixture(); + cli.option('-b, --contractboolean', 'Boolean'); + cli.option('-r, --contractrequired ', 'Required'); + cli.option('--contractoptional [value]', 'Optional'); + expect(extract(cli).globalOptions).toEqual([ + { + flags: ['-b', '--contractboolean'], + description: 'Boolean', + valueMode: 'none', + }, + { + flags: ['-r', '--contractrequired'], + description: 'Required', + valueMode: 'required', + }, + { + flags: ['--contractoptional'], + description: 'Optional', + valueMode: 'optional', + }, + ]); + }); + + it('preserves negative spelling, dot options and nested placeholders', () => { + const cli = fixture(); + cli.option('--no-contractcolor', 'Negative'); + cli.option('--contractinspect [[host:]port]', 'Nested placeholder'); + cli.option('--contractcoverage.reporter ', 'Dot option'); + expect(extract(cli).globalOptions).toEqual([ + { + flags: ['--no-contractcolor'], + description: 'Negative', + valueMode: 'none', + }, + { + flags: ['--contractinspect'], + description: 'Nested placeholder', + valueMode: 'optional', + }, + { + flags: ['--contractcoverage.reporter'], + description: 'Dot option', + valueMode: 'required', + }, + ]); + }); + + it('keeps local descriptions and additions without inventing inherited options', () => { + const cli = fixture(); + cli.option('--contractshared ', 'Global description'); + cli + .command('contract') + .option('--contractshared ', 'Local description') + .option('--contractlocal', 'Local only'); + const data = extract(cli); + expect(data.globalOptions).toHaveLength(1); + expect(data.commands[0].options.map((option) => option.description)).toEqual([ + 'Local description', + 'Local only', + ]); + }); + + it('accepts empty inventories and preserves future command definitions', () => { + expect(extract()).toMatchObject({ globalOptions: [], commands: [] }); + const cli = fixture(); + cli + .command('future-command [...filters]', 'Future description') + .alias('future-alias') + .option('--future-flag', 'Future option'); + expect(extract(cli).commands).toEqual([ + { + name: 'future-command', + options: [ + { + flags: ['--future-flag'], + description: 'Future option', + valueMode: 'none', + }, + ], + }, + ]); + }); + + it('rejects duplicate declarations instead of silently replacing them', () => { + const cli = fixture(); + cli.option('--contractduplicate', 'Duplicate'); + cli.globalCommand.options.push(cli.globalCommand.options[0]); + expect(() => extract(cli)).toThrow('Duplicate Vitest option flag'); + }); + + it('rejects ambiguous arity and incompatible inherited declarations', () => { + const cli = fixture(); + cli.option('--contractvalue', 'Global boolean'); + cli.command('contract').option('--contractvalue ', 'Local required value'); + expect(() => extract(cli)).toThrow('Incompatible inherited Vitest option'); + const ambiguous = fixture(); + ambiguous.option('--contractambiguous', 'Ambiguous'); + ambiguous.globalCommand.options[0].required = true; + expect(() => extract(ambiguous)).toThrow('Unsupported Vitest option arity'); + }); + + it('rejects unsupported spelling instead of emitting unusable metadata', () => { + const cli = fixture(); + cli.option('--contractflag', 'Invalid spelling'); + cli.globalCommand.options[0].rawName = '--bad/flag'; + expect(() => extract(cli)).toThrow('Unsupported Vitest option declaration'); + }); + + it.each(['-ab, --contractflag', '--' + 'x'.repeat(257)])( + 'rejects declarations outside the Rust flag schema: %s', + (rawName) => { + const cli = fixture(); + cli.option('--contractflag', 'Flag'); + cli.globalCommand.options[0].rawName = rawName; + expect(() => extract(cli)).toThrow('Unsupported Vitest option declaration'); + }, + ); + + it('rejects inherited alias collisions across different primary options', () => { + const cli = fixture(); + cli.option('-s, --contractshared', 'Global'); + cli.command('contract').option('-s, --contractlocal', 'Local'); + expect(() => extract(cli)).toThrow('Conflicting inherited Vitest option flag'); + }); + + it('enforces command uniqueness and byte/count schema boundaries', () => { + const invalidName = fixture(); + invalidName.command('contract'); + invalidName.commands[0].name = 'bad/name'; + expect(() => extract(invalidName)).toThrow('Unsupported Vitest command declaration'); + const duplicate = fixture(); + duplicate.command('contract'); + duplicate.command('contract'); + expect(() => extract(duplicate)).toThrow('Unsupported Vitest command declaration'); + const oversized = fixture(); + oversized.option('--contractflag', '文'.repeat(5462)); + expect(() => extract(oversized)).toThrow('Unsupported Vitest option declaration'); + const excessive = fixture(); + for (let i = 0; i < 65; i++) { + excessive.command(`command-${i}`); + } + expect(() => extract(excessive)).toThrow('exceeds schema limits'); + const tooLarge = fixture(); + for (let i = 0; i < 70; i++) { + tooLarge.option(`--contract-${i}`, 'x'.repeat(16384)); + } + expect(() => extract(tooLarge)).toThrow('exceeds schema limits'); + }); + + it('writes a package-relative artifact and rejects a mismatched runner', async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'vp-completion-')); + temporary.push(directory); + await writeFile(path.join(directory, 'package.json'), '{"name":"vite-plus","version":"1.2.0"}'); + const require = createRequire(import.meta.url); + // Resolve through the same CLI package used by these tests, including pnpm layouts. + const dependencyDirectory = path.resolve( + path.dirname(require.resolve('vitest/package.json')), + '..', + ); + await symlink(dependencyDirectory, path.join(directory, 'node_modules'), 'junction'); + const metadata = await writeTestCompletionMetadata(directory, VITEST_VERSION); + expect( + JSON.parse(await readFile(path.join(directory, 'dist/completion/test.json'), 'utf8')), + ).toEqual(metadata); + await expect(writeTestCompletionMetadata(directory, '0.0.0')).rejects.toThrow( + 'Expected vitest@', + ); + }); +}); diff --git a/packages/cli/tests/completion/metadata.integration.ts b/packages/cli/tests/completion/metadata.integration.ts new file mode 100644 index 0000000000..b6da6ecaf6 --- /dev/null +++ b/packages/cli/tests/completion/metadata.integration.ts @@ -0,0 +1,161 @@ +import { spawnSync } from 'node:child_process'; +import { + chmod, + copyFile, + access, + readdir, + mkdtemp, + mkdir, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it } from 'vite-plus/test'; + +import { resolveCompletionConsumer } from '../../../tools/src/completion-consumer.ts'; +import { writeTestCompletionMetadata } from '../../build/completion-metadata.ts'; +import { VITEST_VERSION } from '../../src/utils/constants.ts'; +const temporary: string[] = []; +afterEach(async () => { + await Promise.all(temporary.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); +describe('generated metadata through native Rust', () => { + // Use a fresh Cargo artifact even when pnpm puts the local JS CLI first. + // Missing artifacts are an error, not a reason to use PATH or skip this test. + it('passes the real generated artifact through the Rust completion consumer', async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'vp-completion-consumer-')); + temporary.push(directory); + await writeFile(path.join(directory, 'package.json'), '{"name":"vite-plus","version":"1.3.0"}'); + const require = createRequire(import.meta.url); + await symlink( + path.resolve(path.dirname(require.resolve('vitest/package.json')), '..'), + path.join(directory, 'node_modules'), + 'junction', + ); + const data = await writeTestCompletionMetadata(directory, VITEST_VERSION); + const consumerDirectory = path.join(directory, 'consumer-bin'); + await mkdir(consumerDirectory); + const consumer = path.join(consumerDirectory, process.platform === 'win32' ? 'vp.exe' : 'vp'); + await copyFile(resolveCompletionConsumer(), consumer); + const project = path.join(directory, 'project'); + await mkdir(project); + await writeFile( + path.join(project, 'vite.config.ts'), + "throw new Error('Completion must not load config');", + ); + const shadowDirectory = path.join(project, 'node_modules', '.bin'); + await mkdir(shadowDirectory, { recursive: true }); + const shadow = path.join(shadowDirectory, process.platform === 'win32' ? 'vp.cmd' : 'vp'); + await writeFile( + shadow, + process.platform === 'win32' + ? '@echo Unexpected PATH consumer 1>&2\r\n@exit /b 93\r\n' + : '#!/bin/sh\necho "Unexpected PATH consumer" >&2\nexit 93\n', + ); + if (process.platform !== 'win32') { + await chmod(shadow, 0o755); + } + const run = data.commands.find((command) => command.name === 'run'); + expect(run, 'The Vite+ implicit run mapping needs a current run definition').toBeDefined(); + const scopes = [ + { name: '', options: [...data.globalOptions, ...run!.options] }, + ...data.commands + .filter((command) => + ['run', 'watch', 'dev', 'related', 'bench', 'list', 'doctor'].includes(command.name), + ) + .map((command) => ({ + name: command.name, + options: [...data.globalOptions, ...command.options], + })), + ]; + for (const scope of scopes) { + const flags = [...new Set(scope.options.flatMap((option) => option.flags))]; + const flag = flags.find( + (candidate) => + candidate.startsWith('--') && + candidate.length >= 8 && + flags.filter((value) => value.startsWith(candidate.slice(0, -1))).length === 1, + ); + expect(flag, `Need a usable dynamic probe for ${scope.name || 'implicit run'}`).toBeDefined(); + const words = ['vp', 'test', ...(scope.name ? [scope.name] : []), flag!.slice(0, -1)]; + const result = spawnSync(consumer, ['--', ...words], { + cwd: project, + encoding: 'utf8', + timeout: 15000, + env: { + ...process.env, + PATH: shadowDirectory + path.delimiter + (process.env.PATH ?? ''), + VP_HOME: path.join(directory, 'vp-home'), + VP_SKIP_DEPS_INSTALL: '1', + VP_NODE_MANAGER: 'yes', + VP_GLOBAL_CLI_JS_SCRIPTS_DIR: path.join(directory, 'dist'), + VP_COMPLETE: 'bash', + _CLAP_COMPLETE_INDEX: String(words.length - 1), + _CLAP_IFS: '\n', + }, + }); + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + expect(result.stderr).toBe(''); + expect(result.stdout.split('\n').map((line) => line.split('\t')[0])).toContain(flag); + } + }); + + it.each(['registration', 'request'])( + 'handles uninitialized %s without first-start installation', + async (mode) => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'vp-uninitialized-completion-')); + temporary.push(directory); + const bin = path.join(directory, 'bin'); + const home = path.join(directory, 'home'); + const project = path.join(directory, 'project'); + await Promise.all([bin, home, project].map((entry) => mkdir(entry))); + const consumer = path.join(bin, process.platform === 'win32' ? 'vp.exe' : 'vp'); + await copyFile(resolveCompletionConsumer(), consumer); + // A regression must remain offline on Windows too, where setup needs a sibling shim. + if (process.platform === 'win32') { + await copyFile(consumer, path.join(bin, 'vp-shim.exe')); + } + const vpHome = path.join(directory, 'vp-home'); + const result = spawnSync( + consumer, + mode === 'registration' ? [] : ['--', 'vp', 'test', '--'], + { + cwd: project, + encoding: 'utf8', + timeout: 15000, + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + VP_HOME: vpHome, + XDG_CONFIG_HOME: path.join(directory, 'config'), + XDG_CACHE_HOME: path.join(directory, 'cache'), + XDG_DATA_HOME: path.join(directory, 'data'), + VP_GLOBAL_CLI_JS_SCRIPTS_DIR: path.join(directory, 'missing-dist'), + VP_COMPLETE: 'bash', + _CLAP_COMPLETE_INDEX: '2', + _CLAP_IFS: '\n', + // If setup accidentally runs, keep the regression probe offline, then fail on its writes. + VP_SKIP_DEPS_INSTALL: '1', + VP_NODE_MANAGER: 'yes', + CI: 'true', + }, + }, + ); + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + expect(result.stderr).toBe(''); + if (mode === 'registration') { + expect(result.stdout).toContain('VP_COMPLETE'); + } + await expect(access(vpHome)).rejects.toThrow(); + await expect(access(path.join(bin, '.vp-setup-complete'))).rejects.toThrow(); + expect(await readdir(home)).toEqual([]); + }, + ); +}); diff --git a/packages/cli/tests/completion/startup.integration.ts b/packages/cli/tests/completion/startup.integration.ts new file mode 100644 index 0000000000..ef20edb341 --- /dev/null +++ b/packages/cli/tests/completion/startup.integration.ts @@ -0,0 +1,238 @@ +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + chmodSync, + copyFileSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + readlinkSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it } from 'vite-plus/test'; +import { createCLI } from 'vitest/node'; + +import { resolveCompletionConsumer } from '../../../tools/src/completion-consumer.ts'; +import { extractCompletionMetadata } from '../../build/completion-metadata.ts'; +import { VITEST_VERSION } from '../../src/utils/constants.ts'; + +const temporary: string[] = []; +afterEach(() => { + for (const root of temporary.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); +const binaryName = process.platform === 'win32' ? 'vp.exe' : 'vp'; +function fixture() { + const root = mkdtempSync(path.join(os.tmpdir(), 'vp-completion-startup-')); + temporary.push(root); + const home = path.join(root, 'home'), + project = path.join(root, 'project'), + raw = path.join(root, 'external', binaryName); + mkdirSync(home); + mkdirSync(project); + mkdirSync(path.dirname(raw)); + copyFileSync(resolveCompletionConsumer(), raw); + if (process.platform === 'win32') { + copyFileSync(raw, path.join(path.dirname(raw), 'vp-shim.exe')); + } + writeFileSync(path.join(project, 'package.json'), '{"private":true}'); + writeFileSync( + path.join(project, 'vite.config.ts'), + "throw Error('Completion must not load config')", + ); + const canary = path.join(root, 'canary'); + mkdirSync(canary); + const log = path.join(root, 'node-called'); + writeFileSync( + path.join(canary, 'node'), + `#!/bin/sh\nprintf called >> '${log.replaceAll("'", "'\\''")}'\nexit 93\n`, + ); + chmodSync(path.join(canary, 'node'), 0o755); + writeFileSync( + path.join(canary, 'node.cmd'), + `@echo off\r\necho called >> "${log}"\r\nexit /b 93\r\n`, + ); + const env: NodeJS.ProcessEnv = { + ...process.env, + HOME: home, + USERPROFILE: home, + VP_HOME: path.join(home, '.vite-plus'), + PATH: canary + path.delimiter + (process.env.PATH ?? ''), + CI: 'true', + VP_SKIP_DEPS_INSTALL: '1', + VP_NODE_MANAGER: 'no', + }; + for (const key of [ + 'VP_VERSION', + 'VP_GLOBAL_CLI_JS_SCRIPTS_DIR', + 'VP_SELF_SETUP_SHELL', + 'VP_SELF_SETUP_REPLACE_EXISTING', + 'VP_COMPLETE', + ]) { + delete env[key]; + } + return { root, home, project, raw, env, log }; +} +function run(f: ReturnType, binary: string, args: string[], completion = true) { + return spawnSync(binary, args, { + cwd: f.project, + encoding: 'utf8', + timeout: 15000, + env: { + ...f.env, + ...(completion ? { VP_COMPLETE: 'bash', _CLAP_COMPLETE_INDEX: '2', _CLAP_IFS: '\n' } : {}), + }, + }); +} +function packageMetadata(pkg: string) { + mkdirSync(path.join(pkg, 'dist', 'completion'), { recursive: true }); + writeFileSync( + path.join(pkg, 'package.json'), + JSON.stringify({ name: 'vite-plus', version: '1.3.0' }), + ); + writeFileSync(path.join(pkg, 'dist', 'bin.js'), "throw Error('Completion must not execute JS')"); + const cli = createCLI(); + cli.commands.splice(0); + cli.globalCommand.options.splice(0); + cli.command('run [filters...]').option('--contract-global', 'Controlled completion flag'); + writeFileSync( + path.join(pkg, 'dist/completion/test.json'), + JSON.stringify(extractCompletionMetadata(cli, '1.3.0', VITEST_VERSION)), + ); +} +function vitestPackage(dir: string) { + mkdirSync(dir, { recursive: true }); + writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ name: 'vitest', version: VITEST_VERSION }), + ); +} +function snapshot(root: string): Record { + const result: Record = {}; + function walk(dir: string) { + for (const name of readdirSync(dir).toSorted()) { + const file = path.join(dir, name), + st = lstatSync(file); + const key = path.relative(root, file); + if (st.isSymbolicLink()) { + result[key] = `link:${readlinkSync(file)}`; + } else if (st.isDirectory()) { + result[key] = 'directory'; + walk(file); + } else { + result[key] = createHash('sha256').update(readFileSync(file)).digest('hex'); + } + } + } + walk(root); + return result; +} +const request = ['--', 'vp', 'test', '--contract-glo']; + +describe('read-only completion before first-start setup', () => { + it.each(['flat', 'pnpm-link'])( + 'finds the global package without local dependencies in %s layout', + (layout) => { + const f = fixture(), + prefix = path.join(f.root, 'global'), + logical = path.join(prefix, 'node_modules/vite-plus'); + const pkg = + layout === 'flat' ? logical : path.join(prefix, '.pnpm/cli/node_modules/vite-plus'); + packageMetadata(pkg); + vitestPackage(path.join(path.dirname(pkg), 'vitest')); + if (layout === 'pnpm-link') { + mkdirSync(path.dirname(logical), { recursive: true }); + symlinkSync(pkg, logical, 'junction'); + } + const binary = path.join(prefix, 'bin', binaryName); + mkdirSync(path.dirname(binary)); + copyFileSync(f.raw, binary); + writeFileSync(path.join(path.dirname(binary), '.vp-setup-complete'), ''); + const before = snapshot(f.root), + result = run(f, binary, request); + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + expect(result.stderr).toBe(''); + expect(result.stdout.split('\n').map((l) => l.split('\t')[0])).toContain('--contract-global'); + expect(snapshot(f.root)).toEqual(before); + expect(existsSync(f.log)).toBe(false); + }, + ); + + it.each([ + 'valid', + 'missing', + 'malformed', + 'stale-source', + 'missing-target', + 'missing-marker', + 'self-reference', + ...(process.platform === 'win32' ? [] : ['target-alias-to-source', 'non-executable-target']), + ])('uses only a completed installation for %s receipt', (mode) => { + const f = fixture(); + // Create the receipt using the real installer, offline; completion must never repeat it. + const setup = run(f, f.raw, ['--help'], false); + expect(setup.error).toBeUndefined(); + expect(setup.status, setup.stderr).toBe(0); + const current = path.join(f.env.VP_HOME!, 'current'), + deployed = path.join(current, 'bin', binaryName); + const pkg = path.join(current, 'node_modules/vite-plus'); + packageMetadata(pkg); + vitestPackage(path.join(pkg, 'node_modules/vitest')); + const records = path.join(f.env.VP_HOME!, 'self-setup'), + file = path.join(records, readdirSync(records)[0]); + const receipt = JSON.parse(readFileSync(file, 'utf8')); + if (mode === 'missing') { + rmSync(file); + } + if (mode === 'malformed') { + writeFileSync(file, '{'); + } + if (mode === 'stale-source') { + receipt.source.len += 1; + writeFileSync(file, JSON.stringify(receipt)); + } + if (mode === 'non-executable-target') { + chmodSync(deployed, 0o600); + } + if (mode === 'missing-target') { + rmSync(deployed); + } + if (mode === 'missing-marker') { + rmSync(path.join(path.dirname(deployed), '.vp-setup-complete')); + } + if (mode === 'self-reference') { + receipt.binary = f.raw; + writeFileSync(file, JSON.stringify(receipt)); + } + if (mode === 'target-alias-to-source') { + rmSync(deployed); + symlinkSync(f.raw, deployed); + } + const before = snapshot(f.root), + result = run(f, f.raw, request); + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + expect(result.stderr).toBe(''); + if (mode === 'valid') { + expect(result.stdout.split('\n').map((l) => l.split('\t')[0])).toContain('--contract-global'); + } else { + expect(result.stdout).toBe(''); + } + const registration = run(f, f.raw, []); + expect(registration.status, registration.stderr).toBe(0); + expect(registration.stderr).toBe(''); + expect(registration.stdout).toContain('VP_COMPLETE'); + expect(snapshot(f.root)).toEqual(before); + expect(existsSync(f.log)).toBe(false); + }); +}); diff --git a/packages/cli/tests/completion/vitest.config.ts b/packages/cli/tests/completion/vitest.config.ts new file mode 100644 index 0000000000..4a933d3f3e --- /dev/null +++ b/packages/cli/tests/completion/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['packages/cli/tests/completion/**/*.integration.ts'], + // A receipt case starts setup and completion in separate bounded native processes. + testTimeout: 45_000, + }, +}); diff --git a/packages/tools/src/completion-consumer.ts b/packages/tools/src/completion-consumer.ts new file mode 100644 index 0000000000..f1b056e17c --- /dev/null +++ b/packages/tools/src/completion-consumer.ts @@ -0,0 +1,71 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, statSync } from 'node:fs'; +import path from 'node:path'; + +const repository = path.resolve(import.meta.dirname, '../../..'); + +/** Use only the artifact supplied by the build or CI restore step, never PATH or mtime. */ +export function resolveCompletionConsumer( + root = repository, + env: NodeJS.ProcessEnv = process.env, +): string { + const supplied = env.VP_COMPLETION_CONSUMER_BIN; + if (!supplied) { + throw new Error( + 'Missing VP_COMPLETION_CONSUMER_BIN. Run pnpm test:completion (builds Rust), or export the exact restored CI artifact before running tests.', + ); + } + const binary = path.resolve(root, supplied); + const status = existsSync(binary) ? statSync(binary) : undefined; + if (!status?.isFile()) { + throw new Error(`Rust completion consumer does not exist: ${binary}`); + } + if (process.platform !== 'win32' && !(status.mode & 0o111)) { + throw new Error(`Completion consumer must be executable: ${binary}`); + } + return binary; +} + +/** Cargo's executable field already accounts for config, target-dir, target and profile. */ +export function cargoCompletionConsumer(output: string, root: string): string { + const executables = new Set(); + for (const line of output.split('\n').filter(Boolean)) { + const message = JSON.parse(line) as { + reason?: string; + target?: { name?: string; kind?: string[] }; + executable?: string | null; + }; + if ( + message.reason === 'compiler-artifact' && + message.target?.name === 'vp' && + message.target.kind?.includes('bin') && + typeof message.executable === 'string' + ) { + executables.add(path.resolve(root, message.executable)); + } + } + if (executables.size !== 1) { + throw new Error(`Expected one Cargo vp executable, received ${executables.size}`); + } + return [...executables][0]; +} + +export function buildCompletionConsumer( + root = repository, + env: NodeJS.ProcessEnv = process.env, +): string { + const output = execFileSync( + process.platform === 'win32' ? 'cargo.exe' : 'cargo', + ['build', '-p', 'vp_global_cli', '--message-format=json'], + { + cwd: root, + env, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], + maxBuffer: 64 * 1024 * 1024, + }, + ); + return resolveCompletionConsumer(root, { + VP_COMPLETION_CONSUMER_BIN: cargoCompletionConsumer(output, root), + }); +} diff --git a/packages/tools/src/test-completion.ts b/packages/tools/src/test-completion.ts new file mode 100644 index 0000000000..d1b3367f02 --- /dev/null +++ b/packages/tools/src/test-completion.ts @@ -0,0 +1,30 @@ +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; + +import { buildCompletionConsumer, resolveCompletionConsumer } from './completion-consumer.ts'; + +const root = path.resolve(import.meta.dirname, '../../..'); +const binary = process.env.VP_COMPLETION_CONSUMER_BIN + ? resolveCompletionConsumer(root) + : buildCompletionConsumer(root); +console.error(`Rust completion consumer: ${binary}`); +const result = spawnSync( + process.execPath, + [ + 'packages/cli/dist/bin.js', + 'test', + 'run', + '--config', + 'packages/cli/tests/completion/vitest.config.ts', + ...process.argv.slice(2), + ], + { + cwd: root, + env: { ...process.env, VP_COMPLETION_CONSUMER_BIN: binary }, + stdio: 'inherit', + }, +); +if (result.error) { + throw result.error; +} +process.exit(result.status ?? 1);