From bb7cd99f78169e9fdab2d888d74ec6efef5f00e9 Mon Sep 17 00:00:00 2001 From: caorushizi <84996057@qq.com> Date: Sat, 3 Oct 2026 22:26:38 +0800 Subject: [PATCH 1/3] chore: remove tests and CI/CD configuration --- .githooks/pre-commit | 33 - .githooks/pre-push | 26 - .github/workflows/ci.yml | 227 ----- .github/workflows/cli.yml | 514 ---------- .github/workflows/docs.yml | 45 - .gitignore | 5 +- .goreleaser.yaml | 96 -- AGENTS.md | 2 +- ARCHITECTURE.md | 22 +- CONTRIBUTING.md | 87 +- README.md | 9 +- apps/dashboard/package.json | 9 +- .../src/architecture/dependencies.test.ts | 139 --- .../dashboard/src/components/AppMenu.test.tsx | 149 --- apps/dashboard/src/components/TopBar.test.tsx | 157 --- .../src/components/ui/modal-focus.test.tsx | 101 -- .../EnvironmentSelector.test.tsx | 91 -- .../global-variables/GlobalVariables.test.tsx | 290 ------ .../AccountSettings.test.tsx | 104 -- .../CreateProjectDialog.test.tsx | 312 ------ .../features/secrets/SecretsManager.test.tsx | 463 --------- .../features/services/ServicePanel.test.tsx | 155 --- apps/dashboard/src/locales/en-US.json | 10 +- apps/dashboard/src/locales/zh-CN.json | 10 +- apps/dashboard/src/pages/Overview.test.tsx | 449 --------- .../src/pages/ProjectTemplates.test.tsx | 155 --- .../src/pages/WorkspaceHome.test.tsx | 256 ----- apps/dashboard/src/router/routes.test.tsx | 395 -------- apps/dashboard/src/test/setup.ts | 35 - apps/dashboard/vitest.config.ts | 21 - apps/docs/content/docs/en/skills.md | 4 +- apps/docs/content/docs/en/templates.md | 12 +- apps/docs/content/docs/zh/skills.md | 4 +- apps/docs/content/docs/zh/templates.md | 10 +- apps/docs/vercel.json | 34 - mise.toml | 90 +- packages/cli/Taskfile.yml | 6 - packages/cli/go.mod | 1 - .../env/infisical/client_error_test.go | 31 - .../env/infisical/client_projects_test.go | 160 --- .../adapters/env/infisical/config_test.go | 69 -- .../env/infisical/ensure_folder_test.go | 131 --- .../env/infisical/fetch_batch_test.go | 295 ------ .../adapters/env/infisical/fetch_test.go | 85 -- .../env/infisical/global_session_test.go | 77 -- .../adapters/env/infisical/global_test.go | 126 --- .../adapters/env/infisical/init_test.go | 244 ----- .../adapters/env/infisical/paths_test.go | 128 --- .../env/infisical/shared_location_test.go | 191 ---- .../adapters/runtime/mise/download_test.go | 165 ---- .../adapters/runtime/mise/install_test.go | 293 ------ .../adapters/runtime/mise/mise_test.go | 258 ----- .../adapters/runtime/mise/paths_test.go | 94 -- .../workspaceregistry/local/store_test.go | 328 ------- .../application/execution/runtime_test.go | 35 - .../application/execution/scope_test.go | 26 - .../application/execution/workspace_test.go | 90 -- .../application/manifest/service_test.go | 115 --- .../workspace/project_build_settings_test.go | 80 -- .../workspace/project_settings_test.go | 104 -- .../workspace/registry_service_test.go | 410 -------- .../application/workspace/service_test.go | 33 - .../architecture/dependencies_test.go | 118 --- .../cli/internal/bootstrap/cli/root_test.go | 196 ---- .../bootstrap/cli/task_shorthand_test.go | 42 - .../cli/internal/core/backend/catalog_test.go | 165 ---- .../cli/internal/core/template/compat_test.go | 117 --- .../core/template/electron_template_test.go | 71 -- .../core/template/go_template_test.go | 68 -- .../core/template/list_snapshot_test.go | 92 -- .../cli/internal/core/template/node_test.go | 66 -- .../internal/core/template/projects_test.go | 45 - .../cli/internal/core/template/render_test.go | 183 ---- .../internal/core/template/resolve_test.go | 31 - .../workspace/dashboard_dev_fixture_test.go | 45 - .../workspace/environment_defaults_test.go | 12 - .../internal/core/workspace/groups_test.go | 46 - .../core/workspace/manifest_atomic_test.go | 192 ---- .../core/workspace/manifest_roundtrip_test.go | 68 -- .../internal/core/workspace/manifest_test.go | 111 --- .../manifest_workspace_identity_test.go | 119 --- .../core/workspace/node_members_test.go | 70 -- .../internal/core/workspace/overview_test.go | 154 --- .../core/workspace/package_manager_test.go | 47 - .../core/workspace/retired_domains_test.go | 48 - .../internal/core/workspace/strings_test.go | 58 -- .../internal/core/workspace/summary_test.go | 60 -- .../modules/creation/electron_test.go | 381 -------- .../modules/creation/empty_git_test.go | 132 --- .../modules/creation/json_fields_test.go | 41 - .../modules/creation/languages_test.go | 197 ---- .../modules/creation/package_format_test.go | 100 -- .../creation/runnable_templates_test.go | 187 ---- .../internal/modules/creation/service_test.go | 143 --- .../internal/modules/creation/skills_test.go | 80 -- .../modules/creation/workspace_files_test.go | 108 --- .../modules/dependencies/pnpm_test.go | 209 ---- .../modules/dependencies/service_test.go | 376 ------- .../modules/devservice/manager_test.go | 219 ----- .../environment/binding_session_test.go | 174 ---- .../modules/environment/binding_test.go | 323 ------ .../modules/environment/service_test.go | 113 --- .../modules/environment/session_test.go | 44 - .../internal/modules/gowork/workspace_test.go | 61 -- .../cli/internal/modules/hooks/hooks_test.go | 314 ------ .../modules/hooks/native_unix_test.go | 83 -- .../internal/modules/miseconfig/cache_test.go | 83 -- .../cli/internal/modules/miseconfig/config.go | 10 - .../modules/miseconfig/config_test.go | 276 ------ .../modules/miseconfig/dependencies_test.go | 21 - .../internal/modules/miseconfig/trust_test.go | 90 -- .../cli/internal/modules/skills/catalog.go | 3 +- .../internal/modules/skills/catalog_test.go | 43 - .../internal/modules/skills/install_test.go | 370 ------- .../internal/modules/skills/runner_test.go | 95 -- .../internal/modules/tasks/broker_ui_test.go | 55 -- .../modules/tasks/catalog_view_test.go | 97 -- .../internal/modules/tasks/compile_test.go | 118 --- .../internal/modules/tasks/options_test.go | 21 - .../cli/internal/modules/tasks/plan_test.go | 154 --- .../internal/modules/tasks/secrets_test.go | 487 ---------- .../cli/internal/modules/tasks/taskui_test.go | 43 - .../modules/tasks/testdata/catalog-en-US.txt | 15 - .../modules/tasks/testdata/catalog-zh-CN.txt | 15 - .../cli/internal/modules/taskui/copy_test.go | 228 ----- .../internal/modules/taskui/prefix_test.go | 66 -- .../internal/modules/taskui/sidebar_test.go | 162 ---- .../cli/internal/modules/taskui/tree_test.go | 199 ---- .../cli/internal/modules/taskui/ui_test.go | 428 -------- .../modules/toolenv/environment_test.go | 32 - .../platform/configedit/manifest_test.go | 34 - .../platform/configedit/regions_test.go | 55 -- .../internal/platform/configedit/toml_test.go | 134 --- .../cli/internal/platform/errors/codes.go | 5 +- .../internal/platform/errors/codes_test.go | 122 --- .../cli/internal/platform/fsutil/plan_test.go | 104 -- .../platform/fsutil/replace_windows_test.go | 71 -- .../internal/platform/i18n/catalog_test.go | 125 --- packages/cli/internal/platform/i18n/detect.go | 2 +- .../cli/internal/platform/i18n/detect_test.go | 91 -- .../cli/internal/platform/i18n/i18n_test.go | 95 -- .../internal/platform/i18n/locales/en-US.json | 2 +- .../internal/platform/i18n/locales/zh-CN.json | 2 +- .../platform/infisicalsession/commit_test.go | 90 -- .../platform/infisicalsession/login_test.go | 91 -- .../platform/infisicalsession/session_test.go | 192 ---- .../internal/platform/jsonedit/sort_test.go | 16 - .../platform/jsonedit/strings_test.go | 39 - packages/cli/internal/platform/output/emit.go | 3 - .../cli/internal/platform/output/emit_test.go | 237 ----- packages/cli/internal/platform/output/mode.go | 4 +- .../platform/preferences/preferences_test.go | 116 --- .../platform/process/command_windows_test.go | 104 -- .../cli/internal/platform/prompt/form_test.go | 29 - .../internal/platform/prompt/prompt_test.go | 49 - .../internal/platform/prompt/spinner_test.go | 63 -- .../internal/platform/prompt/theme_test.go | 34 - .../platform/updatecheck/background_test.go | 198 ---- .../platform/updatecheck/cache_test.go | 120 --- .../platform/updatecheck/checker_test.go | 88 -- .../platform/updatecheck/install_test.go | 219 ----- .../platform/updatecheck/manual_test.go | 65 -- .../platform/updatecheck/notify_test.go | 239 ----- .../platform/updatecheck/semver_test.go | 73 -- .../updatecheck/worker_integration_test.go | 264 ----- .../internal/platform/userdirs/home_test.go | 18 - .../internal/ports/secrets/envname_test.go | 26 - .../cli/internal/ports/secrets/loader_test.go | 86 -- .../ports/secrets/merge_windows_test.go | 29 - .../resources/bundled/bundled_test.go | 272 ------ .../internal/transport/cobra/add/cmd_test.go | 85 -- .../transport/cobra/create/name_test.go | 20 - .../transport/cobra/create/result_test.go | 44 - .../internal/transport/cobra/env/bind_test.go | 156 --- .../transport/cobra/env/global_test.go | 247 ----- .../transport/cobra/env/input_test.go | 85 -- .../transport/cobra/env/render_test.go | 59 -- .../internal/transport/cobra/env/set_test.go | 43 - .../transport/cobra/exec/cmd_windows_test.go | 33 - .../transport/cobra/exec/global_test.go | 39 - .../transport/cobra/exec/secrets_test.go | 125 --- .../transport/cobra/hooks/cmd_test.go | 142 --- .../transport/cobra/serve/cmd_test.go | 77 -- .../transport/cobra/skills/cmd_test.go | 55 -- .../transport/cobra/upgrade/cmd_test.go | 114 --- .../transport/http/handlers_catalog_test.go | 72 -- .../http/handlers_preferences_test.go | 120 --- .../http/handlers_project_create_test.go | 261 ----- .../transport/http/handlers_secrets_test.go | 48 - .../transport/http/handlers_services_test.go | 76 -- .../transport/http/handlers_session_test.go | 121 --- .../http/handlers_workspace_bind_test.go | 62 -- .../http/handlers_workspace_mutate_test.go | 218 ----- .../transport/http/handlers_workspace_test.go | 113 --- .../http/handlers_workspaces_test.go | 328 ------- .../internal/transport/http/helpers_test.go | 67 -- packages/cli/pkg/toolchain/adapter.go | 5 +- packages/cli/pkg/toolchain/registry_test.go | 70 -- packages/cli/pkg/toolchain/types.go | 9 +- .../dashboard-dev-workspace/one.manifest.toml | 21 - .../cli/testdata/reference/add-go-api.json | 7 - .../testdata/reference/create-default.json | 9 - .../reference/create-non-empty-error.json | 20 - .../reference/error-template-not-found.json | 1 - .../reference/error-template-required.json | 1 - .../reference/error-unknown-command.json | 1 - packages/cli/testdata/reference/help/add.txt | 21 - .../cli/testdata/reference/help/create.txt | 22 - packages/cli/testdata/reference/help/env.txt | 26 - .../cli/testdata/reference/help/env_bind.txt | 27 - .../cli/testdata/reference/help/env_list.txt | 13 - .../cli/testdata/reference/help/env_set.txt | 18 - .../cli/testdata/reference/help/env_unset.txt | 13 - packages/cli/testdata/reference/help/exec.txt | 24 - packages/cli/testdata/reference/help/hk.txt | 18 - packages/cli/testdata/reference/help/init.txt | 13 - .../testdata/reference/help/init_hooks.txt | 15 - .../cli/testdata/reference/help/init_mise.txt | 17 - .../cli/testdata/reference/help/locale.txt | 14 - .../cli/testdata/reference/help/login.txt | 10 - .../cli/testdata/reference/help/logout.txt | 9 - packages/cli/testdata/reference/help/mise.txt | 17 - packages/cli/testdata/reference/help/root.txt | 29 - packages/cli/testdata/reference/help/run.txt | 26 - .../cli/testdata/reference/help/serve.txt | 17 - .../cli/testdata/reference/help/skills.txt | 23 - .../cli/testdata/reference/help/templates.txt | 12 - .../reference/help/templates_list.txt | 9 - .../cli/testdata/reference/help/upgrade.txt | 22 - .../cli/testdata/reference/help/whoami.txt | 9 - packages/cli/testdata/reference/serve.json | 7 - .../cli/testdata/reference/templates.json | 192 ---- packages/cli/testdata/reference/version.txt | 1 - packages/cli/tests/e2e/agents_locale_test.go | 55 -- packages/cli/tests/e2e/build_test.go | 227 ----- packages/cli/tests/e2e/build_unix_test.go | 88 -- .../tests/e2e/create_removed_flags_test.go | 29 - .../cli/tests/e2e/dependencies_unix_test.go | 114 --- packages/cli/tests/e2e/e2e_helpers_test.go | 353 ------- .../cli/tests/e2e/electron_composite_test.go | 109 --- packages/cli/tests/e2e/empty_project_test.go | 84 -- packages/cli/tests/e2e/install_ps1_test.go | 139 --- packages/cli/tests/e2e/install_sh_test.go | 158 --- .../tests/e2e/locale_terminal_unix_test.go | 35 - packages/cli/tests/e2e/locale_test.go | 90 -- packages/cli/tests/e2e/mise_runtime_test.go | 449 --------- .../cli/tests/e2e/mise_runtime_unix_test.go | 202 ---- packages/cli/tests/e2e/mise_trust_test.go | 104 -- .../cli/tests/e2e/process_compose_test.go | 72 -- .../tests/e2e/repository_workspace_test.go | 71 -- packages/cli/tests/e2e/secret_output_test.go | 135 --- .../cli/tests/e2e/serve_browser_linux_test.go | 100 -- packages/cli/tests/e2e/services_unix_test.go | 133 --- .../tests/e2e/skills_terminal_unix_test.go | 94 -- packages/cli/tests/e2e/skills_test.go | 121 --- .../cli/tests/e2e/snapshot_e2e_add_test.go | 257 ----- .../cli/tests/e2e/snapshot_e2e_create_test.go | 287 ------ .../cli/tests/e2e/snapshot_e2e_dev_test.go | 127 --- .../cli/tests/e2e/snapshot_e2e_env_test.go | 71 -- .../cli/tests/e2e/snapshot_e2e_run_test.go | 250 ----- .../cli/tests/e2e/snapshot_e2e_serve_test.go | 149 --- packages/cli/tests/e2e/snapshot_e2e_test.go | 59 -- .../tests/e2e/snapshot_e2e_tty_unix_test.go | 130 --- .../cli/tests/e2e/snapshot_e2e_ux_test.go | 228 ----- packages/cli/tests/e2e/snapshot_help_test.go | 190 ---- .../tests/e2e/startup_terminal_unix_test.go | 75 -- packages/cli/tests/e2e/task_shortcuts_test.go | 177 ---- .../cli/tests/e2e/task_terminal_unix_test.go | 469 --------- packages/cli/tests/e2e/tasks_cache_test.go | 212 ---- packages/cli/tools/install-local/main_test.go | 169 ---- packages/cli/tools/repo-checks/main_test.go | 54 -- .../cli/tools/sync-resources/main_test.go | 92 -- .../tools/verify-cli-references/main_test.go | 33 - packages/cli/tools/verify-help/main.go | 16 +- packages/cli/tools/verify-help/main_test.go | 50 - packages/kernel/Taskfile.yml | 4 - .../kernel/pkg/kernel/architecture_test.go | 53 - packages/kernel/pkg/kernel/context_test.go | 36 - packages/kernel/pkg/kernel/lifecycle_test.go | 53 - .../templates/electron-app/.oxlintrc.json | 3 +- packages/templates/electron-app/package.json | 1 - .../one-template-electron-main/package.json | 4 +- .../test/dev-environment.test.mjs | 444 --------- .../test/main.test.ts | 144 --- .../test/scripts.test.mjs | 117 --- .../one-template-electron-main/tsconfig.json | 9 +- packages/templates/electron-app/template.json | 3 +- packages/templates/expo-mobile/README.md | 4 +- .../templates/expo-mobile/jest.config.cjs | 6 - packages/templates/expo-mobile/package.json | 6 - .../expo-mobile/src/hooks/setup/swr.test.ts | 48 - packages/templates/expo-mobile/tsconfig.json | 15 +- packages/templates/fumadocs-docs/.gitignore | 6 - packages/templates/go-api/.gitignore | 1 - packages/templates/go-api/README.md | 4 +- packages/templates/go-api/Taskfile.yml | 4 - .../go-api/internal/http/router_test.go | 47 - packages/templates/go-lib/.gitignore | 2 - packages/templates/go-lib/README.md | 3 +- packages/templates/go-lib/Taskfile.yml | 9 - .../go-lib/pkg/greeter/greeter_test.go | 21 - packages/templates/nestjs-api/.gitignore | 1 - packages/templates/nestjs-api/README.md | 4 +- packages/templates/nestjs-api/package.json | 31 - .../nestjs-api/src/app.controller.spec.ts | 9 - .../templates/nestjs-api/test/app.e2e-spec.ts | 41 - .../templates/nestjs-api/test/jest-e2e.json | 12 - .../templates/nestjs-api/tsconfig.build.json | 9 +- packages/templates/nestjs-api/tsconfig.json | 13 +- packages/templates/nextjs-app/.gitignore | 6 - packages/templates/nextjs-site/.gitignore | 6 - packages/templates/registry.json | 5 +- packages/templates/ts-library/.gitignore | 1 - packages/templates/ts-library/README.md | 8 - packages/templates/ts-library/package.json | 9 +- .../templates/ts-library/src/index.test.ts | 14 - packages/templates/ts-library/tsconfig.json | 13 +- .../templates/ts-library/vitest.config.ts | 12 - pnpm-lock.yaml | 917 ------------------ pnpm-workspace.yaml | 1 - vercel.json | 34 - 321 files changed, 130 insertions(+), 32020 deletions(-) delete mode 100755 .githooks/pre-commit delete mode 100755 .githooks/pre-push delete mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/cli.yml delete mode 100644 .github/workflows/docs.yml delete mode 100644 .goreleaser.yaml delete mode 100644 apps/dashboard/src/architecture/dependencies.test.ts delete mode 100644 apps/dashboard/src/components/AppMenu.test.tsx delete mode 100644 apps/dashboard/src/components/TopBar.test.tsx delete mode 100644 apps/dashboard/src/components/ui/modal-focus.test.tsx delete mode 100644 apps/dashboard/src/features/environment-context/EnvironmentSelector.test.tsx delete mode 100644 apps/dashboard/src/features/global-variables/GlobalVariables.test.tsx delete mode 100644 apps/dashboard/src/features/infisical-session/AccountSettings.test.tsx delete mode 100644 apps/dashboard/src/features/project-creation/CreateProjectDialog.test.tsx delete mode 100644 apps/dashboard/src/features/secrets/SecretsManager.test.tsx delete mode 100644 apps/dashboard/src/features/services/ServicePanel.test.tsx delete mode 100644 apps/dashboard/src/pages/Overview.test.tsx delete mode 100644 apps/dashboard/src/pages/ProjectTemplates.test.tsx delete mode 100644 apps/dashboard/src/pages/WorkspaceHome.test.tsx delete mode 100644 apps/dashboard/src/router/routes.test.tsx delete mode 100644 apps/dashboard/src/test/setup.ts delete mode 100644 apps/dashboard/vitest.config.ts delete mode 100644 apps/docs/vercel.json delete mode 100644 packages/cli/internal/adapters/env/infisical/client_error_test.go delete mode 100644 packages/cli/internal/adapters/env/infisical/client_projects_test.go delete mode 100644 packages/cli/internal/adapters/env/infisical/config_test.go delete mode 100644 packages/cli/internal/adapters/env/infisical/ensure_folder_test.go delete mode 100644 packages/cli/internal/adapters/env/infisical/fetch_batch_test.go delete mode 100644 packages/cli/internal/adapters/env/infisical/fetch_test.go delete mode 100644 packages/cli/internal/adapters/env/infisical/global_session_test.go delete mode 100644 packages/cli/internal/adapters/env/infisical/global_test.go delete mode 100644 packages/cli/internal/adapters/env/infisical/init_test.go delete mode 100644 packages/cli/internal/adapters/env/infisical/paths_test.go delete mode 100644 packages/cli/internal/adapters/env/infisical/shared_location_test.go delete mode 100644 packages/cli/internal/adapters/runtime/mise/download_test.go delete mode 100644 packages/cli/internal/adapters/runtime/mise/install_test.go delete mode 100644 packages/cli/internal/adapters/runtime/mise/mise_test.go delete mode 100644 packages/cli/internal/adapters/runtime/mise/paths_test.go delete mode 100644 packages/cli/internal/adapters/workspaceregistry/local/store_test.go delete mode 100644 packages/cli/internal/application/execution/runtime_test.go delete mode 100644 packages/cli/internal/application/execution/scope_test.go delete mode 100644 packages/cli/internal/application/execution/workspace_test.go delete mode 100644 packages/cli/internal/application/manifest/service_test.go delete mode 100644 packages/cli/internal/application/workspace/project_build_settings_test.go delete mode 100644 packages/cli/internal/application/workspace/project_settings_test.go delete mode 100644 packages/cli/internal/application/workspace/registry_service_test.go delete mode 100644 packages/cli/internal/application/workspace/service_test.go delete mode 100644 packages/cli/internal/architecture/dependencies_test.go delete mode 100644 packages/cli/internal/bootstrap/cli/root_test.go delete mode 100644 packages/cli/internal/bootstrap/cli/task_shorthand_test.go delete mode 100644 packages/cli/internal/core/backend/catalog_test.go delete mode 100644 packages/cli/internal/core/template/compat_test.go delete mode 100644 packages/cli/internal/core/template/electron_template_test.go delete mode 100644 packages/cli/internal/core/template/go_template_test.go delete mode 100644 packages/cli/internal/core/template/list_snapshot_test.go delete mode 100644 packages/cli/internal/core/template/node_test.go delete mode 100644 packages/cli/internal/core/template/projects_test.go delete mode 100644 packages/cli/internal/core/template/render_test.go delete mode 100644 packages/cli/internal/core/template/resolve_test.go delete mode 100644 packages/cli/internal/core/workspace/dashboard_dev_fixture_test.go delete mode 100644 packages/cli/internal/core/workspace/environment_defaults_test.go delete mode 100644 packages/cli/internal/core/workspace/groups_test.go delete mode 100644 packages/cli/internal/core/workspace/manifest_atomic_test.go delete mode 100644 packages/cli/internal/core/workspace/manifest_roundtrip_test.go delete mode 100644 packages/cli/internal/core/workspace/manifest_test.go delete mode 100644 packages/cli/internal/core/workspace/manifest_workspace_identity_test.go delete mode 100644 packages/cli/internal/core/workspace/node_members_test.go delete mode 100644 packages/cli/internal/core/workspace/overview_test.go delete mode 100644 packages/cli/internal/core/workspace/package_manager_test.go delete mode 100644 packages/cli/internal/core/workspace/retired_domains_test.go delete mode 100644 packages/cli/internal/core/workspace/strings_test.go delete mode 100644 packages/cli/internal/core/workspace/summary_test.go delete mode 100644 packages/cli/internal/modules/creation/electron_test.go delete mode 100644 packages/cli/internal/modules/creation/empty_git_test.go delete mode 100644 packages/cli/internal/modules/creation/json_fields_test.go delete mode 100644 packages/cli/internal/modules/creation/languages_test.go delete mode 100644 packages/cli/internal/modules/creation/package_format_test.go delete mode 100644 packages/cli/internal/modules/creation/runnable_templates_test.go delete mode 100644 packages/cli/internal/modules/creation/service_test.go delete mode 100644 packages/cli/internal/modules/creation/skills_test.go delete mode 100644 packages/cli/internal/modules/creation/workspace_files_test.go delete mode 100644 packages/cli/internal/modules/dependencies/pnpm_test.go delete mode 100644 packages/cli/internal/modules/dependencies/service_test.go delete mode 100644 packages/cli/internal/modules/devservice/manager_test.go delete mode 100644 packages/cli/internal/modules/environment/binding_session_test.go delete mode 100644 packages/cli/internal/modules/environment/binding_test.go delete mode 100644 packages/cli/internal/modules/environment/service_test.go delete mode 100644 packages/cli/internal/modules/environment/session_test.go delete mode 100644 packages/cli/internal/modules/gowork/workspace_test.go delete mode 100644 packages/cli/internal/modules/hooks/hooks_test.go delete mode 100644 packages/cli/internal/modules/hooks/native_unix_test.go delete mode 100644 packages/cli/internal/modules/miseconfig/cache_test.go delete mode 100644 packages/cli/internal/modules/miseconfig/config_test.go delete mode 100644 packages/cli/internal/modules/miseconfig/dependencies_test.go delete mode 100644 packages/cli/internal/modules/miseconfig/trust_test.go delete mode 100644 packages/cli/internal/modules/skills/catalog_test.go delete mode 100644 packages/cli/internal/modules/skills/install_test.go delete mode 100644 packages/cli/internal/modules/skills/runner_test.go delete mode 100644 packages/cli/internal/modules/tasks/broker_ui_test.go delete mode 100644 packages/cli/internal/modules/tasks/catalog_view_test.go delete mode 100644 packages/cli/internal/modules/tasks/compile_test.go delete mode 100644 packages/cli/internal/modules/tasks/options_test.go delete mode 100644 packages/cli/internal/modules/tasks/plan_test.go delete mode 100644 packages/cli/internal/modules/tasks/secrets_test.go delete mode 100644 packages/cli/internal/modules/tasks/taskui_test.go delete mode 100644 packages/cli/internal/modules/tasks/testdata/catalog-en-US.txt delete mode 100644 packages/cli/internal/modules/tasks/testdata/catalog-zh-CN.txt delete mode 100644 packages/cli/internal/modules/taskui/copy_test.go delete mode 100644 packages/cli/internal/modules/taskui/prefix_test.go delete mode 100644 packages/cli/internal/modules/taskui/sidebar_test.go delete mode 100644 packages/cli/internal/modules/taskui/tree_test.go delete mode 100644 packages/cli/internal/modules/taskui/ui_test.go delete mode 100644 packages/cli/internal/modules/toolenv/environment_test.go delete mode 100644 packages/cli/internal/platform/configedit/manifest_test.go delete mode 100644 packages/cli/internal/platform/configedit/regions_test.go delete mode 100644 packages/cli/internal/platform/configedit/toml_test.go delete mode 100644 packages/cli/internal/platform/errors/codes_test.go delete mode 100644 packages/cli/internal/platform/fsutil/plan_test.go delete mode 100644 packages/cli/internal/platform/fsutil/replace_windows_test.go delete mode 100644 packages/cli/internal/platform/i18n/catalog_test.go delete mode 100644 packages/cli/internal/platform/i18n/detect_test.go delete mode 100644 packages/cli/internal/platform/i18n/i18n_test.go delete mode 100644 packages/cli/internal/platform/infisicalsession/commit_test.go delete mode 100644 packages/cli/internal/platform/infisicalsession/login_test.go delete mode 100644 packages/cli/internal/platform/infisicalsession/session_test.go delete mode 100644 packages/cli/internal/platform/jsonedit/sort_test.go delete mode 100644 packages/cli/internal/platform/jsonedit/strings_test.go delete mode 100644 packages/cli/internal/platform/output/emit_test.go delete mode 100644 packages/cli/internal/platform/preferences/preferences_test.go delete mode 100644 packages/cli/internal/platform/process/command_windows_test.go delete mode 100644 packages/cli/internal/platform/prompt/form_test.go delete mode 100644 packages/cli/internal/platform/prompt/prompt_test.go delete mode 100644 packages/cli/internal/platform/prompt/spinner_test.go delete mode 100644 packages/cli/internal/platform/prompt/theme_test.go delete mode 100644 packages/cli/internal/platform/updatecheck/background_test.go delete mode 100644 packages/cli/internal/platform/updatecheck/cache_test.go delete mode 100644 packages/cli/internal/platform/updatecheck/checker_test.go delete mode 100644 packages/cli/internal/platform/updatecheck/install_test.go delete mode 100644 packages/cli/internal/platform/updatecheck/manual_test.go delete mode 100644 packages/cli/internal/platform/updatecheck/notify_test.go delete mode 100644 packages/cli/internal/platform/updatecheck/semver_test.go delete mode 100644 packages/cli/internal/platform/updatecheck/worker_integration_test.go delete mode 100644 packages/cli/internal/platform/userdirs/home_test.go delete mode 100644 packages/cli/internal/ports/secrets/envname_test.go delete mode 100644 packages/cli/internal/ports/secrets/loader_test.go delete mode 100644 packages/cli/internal/ports/secrets/merge_windows_test.go delete mode 100644 packages/cli/internal/resources/bundled/bundled_test.go delete mode 100644 packages/cli/internal/transport/cobra/add/cmd_test.go delete mode 100644 packages/cli/internal/transport/cobra/create/name_test.go delete mode 100644 packages/cli/internal/transport/cobra/create/result_test.go delete mode 100644 packages/cli/internal/transport/cobra/env/bind_test.go delete mode 100644 packages/cli/internal/transport/cobra/env/global_test.go delete mode 100644 packages/cli/internal/transport/cobra/env/input_test.go delete mode 100644 packages/cli/internal/transport/cobra/env/render_test.go delete mode 100644 packages/cli/internal/transport/cobra/env/set_test.go delete mode 100644 packages/cli/internal/transport/cobra/exec/cmd_windows_test.go delete mode 100644 packages/cli/internal/transport/cobra/exec/global_test.go delete mode 100644 packages/cli/internal/transport/cobra/exec/secrets_test.go delete mode 100644 packages/cli/internal/transport/cobra/hooks/cmd_test.go delete mode 100644 packages/cli/internal/transport/cobra/serve/cmd_test.go delete mode 100644 packages/cli/internal/transport/cobra/skills/cmd_test.go delete mode 100644 packages/cli/internal/transport/cobra/upgrade/cmd_test.go delete mode 100644 packages/cli/internal/transport/http/handlers_catalog_test.go delete mode 100644 packages/cli/internal/transport/http/handlers_preferences_test.go delete mode 100644 packages/cli/internal/transport/http/handlers_project_create_test.go delete mode 100644 packages/cli/internal/transport/http/handlers_secrets_test.go delete mode 100644 packages/cli/internal/transport/http/handlers_services_test.go delete mode 100644 packages/cli/internal/transport/http/handlers_session_test.go delete mode 100644 packages/cli/internal/transport/http/handlers_workspace_bind_test.go delete mode 100644 packages/cli/internal/transport/http/handlers_workspace_mutate_test.go delete mode 100644 packages/cli/internal/transport/http/handlers_workspace_test.go delete mode 100644 packages/cli/internal/transport/http/handlers_workspaces_test.go delete mode 100644 packages/cli/internal/transport/http/helpers_test.go delete mode 100644 packages/cli/pkg/toolchain/registry_test.go delete mode 100644 packages/cli/testdata/dashboard-dev-workspace/one.manifest.toml delete mode 100644 packages/cli/testdata/reference/add-go-api.json delete mode 100644 packages/cli/testdata/reference/create-default.json delete mode 100644 packages/cli/testdata/reference/create-non-empty-error.json delete mode 100644 packages/cli/testdata/reference/error-template-not-found.json delete mode 100644 packages/cli/testdata/reference/error-template-required.json delete mode 100644 packages/cli/testdata/reference/error-unknown-command.json delete mode 100644 packages/cli/testdata/reference/help/add.txt delete mode 100644 packages/cli/testdata/reference/help/create.txt delete mode 100644 packages/cli/testdata/reference/help/env.txt delete mode 100644 packages/cli/testdata/reference/help/env_bind.txt delete mode 100644 packages/cli/testdata/reference/help/env_list.txt delete mode 100644 packages/cli/testdata/reference/help/env_set.txt delete mode 100644 packages/cli/testdata/reference/help/env_unset.txt delete mode 100644 packages/cli/testdata/reference/help/exec.txt delete mode 100644 packages/cli/testdata/reference/help/hk.txt delete mode 100644 packages/cli/testdata/reference/help/init.txt delete mode 100644 packages/cli/testdata/reference/help/init_hooks.txt delete mode 100644 packages/cli/testdata/reference/help/init_mise.txt delete mode 100644 packages/cli/testdata/reference/help/locale.txt delete mode 100644 packages/cli/testdata/reference/help/login.txt delete mode 100644 packages/cli/testdata/reference/help/logout.txt delete mode 100644 packages/cli/testdata/reference/help/mise.txt delete mode 100644 packages/cli/testdata/reference/help/root.txt delete mode 100644 packages/cli/testdata/reference/help/run.txt delete mode 100644 packages/cli/testdata/reference/help/serve.txt delete mode 100644 packages/cli/testdata/reference/help/skills.txt delete mode 100644 packages/cli/testdata/reference/help/templates.txt delete mode 100644 packages/cli/testdata/reference/help/templates_list.txt delete mode 100644 packages/cli/testdata/reference/help/upgrade.txt delete mode 100644 packages/cli/testdata/reference/help/whoami.txt delete mode 100644 packages/cli/testdata/reference/serve.json delete mode 100644 packages/cli/testdata/reference/templates.json delete mode 100644 packages/cli/testdata/reference/version.txt delete mode 100644 packages/cli/tests/e2e/agents_locale_test.go delete mode 100644 packages/cli/tests/e2e/build_test.go delete mode 100644 packages/cli/tests/e2e/build_unix_test.go delete mode 100644 packages/cli/tests/e2e/create_removed_flags_test.go delete mode 100644 packages/cli/tests/e2e/dependencies_unix_test.go delete mode 100644 packages/cli/tests/e2e/e2e_helpers_test.go delete mode 100644 packages/cli/tests/e2e/electron_composite_test.go delete mode 100644 packages/cli/tests/e2e/empty_project_test.go delete mode 100644 packages/cli/tests/e2e/install_ps1_test.go delete mode 100644 packages/cli/tests/e2e/install_sh_test.go delete mode 100644 packages/cli/tests/e2e/locale_terminal_unix_test.go delete mode 100644 packages/cli/tests/e2e/locale_test.go delete mode 100644 packages/cli/tests/e2e/mise_runtime_test.go delete mode 100644 packages/cli/tests/e2e/mise_runtime_unix_test.go delete mode 100644 packages/cli/tests/e2e/mise_trust_test.go delete mode 100644 packages/cli/tests/e2e/process_compose_test.go delete mode 100644 packages/cli/tests/e2e/repository_workspace_test.go delete mode 100644 packages/cli/tests/e2e/secret_output_test.go delete mode 100644 packages/cli/tests/e2e/serve_browser_linux_test.go delete mode 100644 packages/cli/tests/e2e/services_unix_test.go delete mode 100644 packages/cli/tests/e2e/skills_terminal_unix_test.go delete mode 100644 packages/cli/tests/e2e/skills_test.go delete mode 100644 packages/cli/tests/e2e/snapshot_e2e_add_test.go delete mode 100644 packages/cli/tests/e2e/snapshot_e2e_create_test.go delete mode 100644 packages/cli/tests/e2e/snapshot_e2e_dev_test.go delete mode 100644 packages/cli/tests/e2e/snapshot_e2e_env_test.go delete mode 100644 packages/cli/tests/e2e/snapshot_e2e_run_test.go delete mode 100644 packages/cli/tests/e2e/snapshot_e2e_serve_test.go delete mode 100644 packages/cli/tests/e2e/snapshot_e2e_test.go delete mode 100644 packages/cli/tests/e2e/snapshot_e2e_tty_unix_test.go delete mode 100644 packages/cli/tests/e2e/snapshot_e2e_ux_test.go delete mode 100644 packages/cli/tests/e2e/snapshot_help_test.go delete mode 100644 packages/cli/tests/e2e/startup_terminal_unix_test.go delete mode 100644 packages/cli/tests/e2e/task_shortcuts_test.go delete mode 100644 packages/cli/tests/e2e/task_terminal_unix_test.go delete mode 100644 packages/cli/tests/e2e/tasks_cache_test.go delete mode 100644 packages/cli/tools/install-local/main_test.go delete mode 100644 packages/cli/tools/repo-checks/main_test.go delete mode 100644 packages/cli/tools/sync-resources/main_test.go delete mode 100644 packages/cli/tools/verify-cli-references/main_test.go delete mode 100644 packages/cli/tools/verify-help/main_test.go delete mode 100644 packages/kernel/pkg/kernel/architecture_test.go delete mode 100644 packages/kernel/pkg/kernel/context_test.go delete mode 100644 packages/kernel/pkg/kernel/lifecycle_test.go delete mode 100644 packages/templates/electron-app/services/one-template-electron-main/test/dev-environment.test.mjs delete mode 100644 packages/templates/electron-app/services/one-template-electron-main/test/main.test.ts delete mode 100644 packages/templates/electron-app/services/one-template-electron-main/test/scripts.test.mjs delete mode 100644 packages/templates/expo-mobile/jest.config.cjs delete mode 100644 packages/templates/expo-mobile/src/hooks/setup/swr.test.ts delete mode 100644 packages/templates/go-api/internal/http/router_test.go delete mode 100644 packages/templates/go-lib/pkg/greeter/greeter_test.go delete mode 100644 packages/templates/nestjs-api/src/app.controller.spec.ts delete mode 100644 packages/templates/nestjs-api/test/app.e2e-spec.ts delete mode 100644 packages/templates/nestjs-api/test/jest-e2e.json delete mode 100644 packages/templates/ts-library/src/index.test.ts delete mode 100644 packages/templates/ts-library/vitest.config.ts delete mode 100644 vercel.json diff --git a/.githooks/pre-commit b/.githooks/pre-commit deleted file mode 100755 index cf45d993..00000000 --- a/.githooks/pre-commit +++ /dev/null @@ -1,33 +0,0 @@ -#!/bin/sh -set -eu - -repo_root=$(git rev-parse --show-toplevel) -cd "$repo_root" - -unstaged=$(git diff --name-only --ignore-submodules --) -untracked=$(git ls-files --others --exclude-standard) -if [ -n "$unstaged" ] || [ -n "$untracked" ]; then - printf '%s\n' "pre-commit: the working tree must match the staged snapshot before running mise run check." >&2 - if [ -n "$unstaged" ]; then - printf '%s\n%s\n' "Unstaged files:" "$unstaged" >&2 - fi - if [ -n "$untracked" ]; then - printf '%s\n%s\n' "Untracked files:" "$untracked" >&2 - fi - printf '%s\n' "Stage the intended files (or stash unrelated work), then commit again." >&2 - exit 1 -fi - -if ! command -v mise >/dev/null 2>&1; then - printf '%s\n' "pre-commit: mise is required; install it or run 'git commit --no-verify' to bypass intentionally." >&2 - exit 1 -fi - -# Tests create temporary repositories; do not pass this hook's Git context to them. -# 测试会创建临时仓库,不向它们传递提交钩子的 Git 仓库环境。 -for git_env_name in $(git rev-parse --local-env-vars); do - unset "$git_env_name" -done - -printf '%s\n' "pre-commit: running the PR gate (mise run check)" -exec mise run check diff --git a/.githooks/pre-push b/.githooks/pre-push deleted file mode 100755 index 039b83d9..00000000 --- a/.githooks/pre-push +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/sh -set -eu - -repo_root=$(git rev-parse --show-toplevel) -cd "$repo_root" - -changes=$(git status --porcelain --untracked-files=normal --ignore-submodules) -if [ -n "$changes" ]; then - printf '%s\n' "pre-push: commit or stash local changes so checks run against the committed snapshot." >&2 - printf '%s\n' "$changes" >&2 - exit 1 -fi - -if ! command -v mise >/dev/null 2>&1; then - printf '%s\n' "pre-push: mise is required; install it before pushing." >&2 - exit 1 -fi - -# Tests create temporary repositories; do not pass the hook's Git context. -# 测试会创建临时仓库,不向它们传递推送钩子的 Git 仓库环境。 -for git_env_name in $(git rev-parse --local-env-vars); do - unset "$git_env_name" -done - -printf '%s\n' "pre-push: running the PR gate, Go race tests, and template builds" -exec mise run --jobs 1 pre-push diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index b3f82e9c..00000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,227 +0,0 @@ -# PR-time test gate with native Linux, Windows, macOS, and Go race coverage. -# -# Two PR jobs call the same subtasks used by the local `mise run check` entrypoint: -# - lint: `mise run check:static` covers documentation drift, Go vet/gofmt, and -# Dashboard lint/format. The whole bundled -# tree (registry/_templates/_web) is gitignored and regenerated -# via `mise run sync-bundled` + `mise run sync-web`, both of which run as deps -# of `mise run vet` — so vet implicitly bootstraps the bundle here. -# - test: `mise run check:test` builds bin/one then runs Go + Dashboard tests. -# Build is required because tests/e2e snapshot tests skip when -# bin/one is missing. -# Native Windows, macOS, and Go race jobs also run on every PR. The master -# ruleset requires lint, test, test-windows, test-macos, and test-race before merging. -# -# Both jobs need Node + pnpm because sync-web runs `pnpm install` + -# `vite build` against apps/dashboard/. -# -# Runners: -# - ubuntu-latest for the main PR checks -# - windows-latest for native Windows build and test coverage -# - macos-latest for native macOS build and test coverage - -name: Code Quality and Tests - -on: - pull_request: - push: - branches: [master] - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -env: - MISE_TASK_CACHE: "off" - # GitHub-hosted runners are outside China; the default Go proxy - # (goproxy.cn) is slower/flakier there and has caused CI download failures. - GOPROXY: https://proxy.golang.org,direct - GOTOOLCHAIN: local - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - - name: Set up mise - uses: jdx/mise-action@v4 - with: - version: 2026.9.7 - experimental: false - - - name: Download Go modules - run: | - GOWORK=off go -C packages/kernel mod download - GOWORK=off go -C packages/cli mod download - - - name: Verify dependency metadata is clean - run: | - set -euo pipefail - dependency_files=( - go.work - go.work.sum - packages/kernel/go.mod - packages/kernel/go.sum - packages/cli/go.mod - packages/cli/go.sum - ) - changes="$(git status --short -- "${dependency_files[@]}")" - if [[ -n "$changes" ]]; then - echo "Dependency setup changed dependency metadata." >&2 - printf '%s\n' "$changes" >&2 - git diff -- "${dependency_files[@]}" - exit 1 - fi - - - name: Run static gate - # Calls the same static subtask as local `mise run check`. - run: mise run check:static - - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - - name: Set up mise - uses: jdx/mise-action@v4 - with: - version: 2026.9.7 - experimental: false - - - name: Download Go modules - run: | - GOWORK=off go -C packages/kernel mod download - GOWORK=off go -C packages/cli mod download - - - name: Verify dependency metadata is clean - run: | - set -euo pipefail - dependency_files=( - go.work - go.work.sum - packages/kernel/go.mod - packages/kernel/go.sum - packages/cli/go.mod - packages/cli/go.sum - ) - changes="$(git status --short -- "${dependency_files[@]}")" - if [[ -n "$changes" ]]; then - echo "Dependency setup changed dependency metadata." >&2 - printf '%s\n' "$changes" >&2 - git diff -- "${dependency_files[@]}" - exit 1 - fi - - - name: Run test gate - # Avoid running Go builds/tests alongside jsdom on the same runner. - run: mise run --jobs 1 check:test - - - name: Build template sources and generated projects - run: mise run check:templates - - test-windows: - runs-on: windows-latest - steps: - - uses: actions/checkout@v7 - - - name: Set up mise - uses: jdx/mise-action@v4 - with: - version: 2026.9.7 - experimental: false - - - name: Download Go modules - shell: pwsh - run: | - $env:GOWORK = "off" - go -C packages/kernel mod download - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - go -C packages/cli mod download - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - - name: Run native Windows PR gate - run: mise run --jobs 1 check - - test-race: - # Catch Go data races before merging, alongside the other required jobs. - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - - name: Set up mise - uses: jdx/mise-action@v4 - with: - version: 2026.9.7 - experimental: false - - - name: Download Go modules - run: | - GOWORK=off go -C packages/kernel mod download - GOWORK=off go -C packages/cli mod download - - - name: Verify dependency metadata is clean - run: | - set -euo pipefail - dependency_files=( - go.work - go.work.sum - packages/kernel/go.mod - packages/kernel/go.sum - packages/cli/go.mod - packages/cli/go.sum - ) - changes="$(git status --short -- "${dependency_files[@]}")" - if [[ -n "$changes" ]]; then - echo "Dependency setup changed dependency metadata." >&2 - printf '%s\n' "$changes" >&2 - git diff -- "${dependency_files[@]}" - exit 1 - fi - - - name: Run Go race tests - # test:go builds the CLI for E2E coverage; Dashboard tests run in test. - run: mise run test:go - - test-macos: - # Run before merging so macOS path and process regressions block PRs. - runs-on: macos-latest - steps: - - uses: actions/checkout@v7 - - - name: Set up mise - uses: jdx/mise-action@v4 - with: - version: 2026.9.7 - experimental: false - - - name: Download Go modules - run: | - GOWORK=off go -C packages/kernel mod download - GOWORK=off go -C packages/cli mod download - - - name: Verify dependency metadata is clean - run: | - set -euo pipefail - dependency_files=( - go.work - go.work.sum - packages/kernel/go.mod - packages/kernel/go.sum - packages/cli/go.mod - packages/cli/go.sum - ) - changes="$(git status --short -- "${dependency_files[@]}")" - if [[ -n "$changes" ]]; then - echo "Dependency setup changed dependency metadata." >&2 - printf '%s\n' "$changes" >&2 - git diff -- "${dependency_files[@]}" - exit 1 - fi - - - name: Run test gate - run: mise run --jobs 1 check:test diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml deleted file mode 100644 index c5b4a70b..00000000 --- a/.github/workflows/cli.yml +++ /dev/null @@ -1,514 +0,0 @@ -# Build and publish a One CLI release with an automatic semantic-version bump. -# No version files or release branches are created on master. - -name: Build and Release -run-name: Release (${{ inputs.bump_type }}) - -on: - workflow_dispatch: - inputs: - bump_type: - description: "Semantic-version component to increment" - required: true - type: choice - default: patch - options: - - patch - - minor - - major - -concurrency: - group: build-and-release - cancel-in-progress: false - -permissions: - contents: write - -env: - MISE_TASK_CACHE: "off" - GOPROXY: https://proxy.golang.org,direct - GOTOOLCHAIN: local - -jobs: - release: - runs-on: ubuntu-latest - timeout-minutes: 120 - steps: - - name: Check out dispatch commit - uses: actions/checkout@v7 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Validate master - run: | - set -euo pipefail - if [[ "$GITHUB_REF" != "refs/heads/master" ]]; then - echo "Run this workflow from the master branch." >&2 - exit 1 - fi - - git fetch origin master --tags - if [[ "$(git rev-parse HEAD)" != "$(git rev-parse origin/master)" ]]; then - echo "master advanced after this run was dispatched; start a fresh run." >&2 - exit 1 - fi - - - name: Calculate release version - id: version - env: - BUMP_TYPE: ${{ inputs.bump_type }} - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - stable_tag_re='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' - latest_tag="" - while IFS= read -r candidate; do - if [[ "$candidate" =~ $stable_tag_re ]]; then - latest_tag="$candidate" - break - fi - done < <(git tag --list 'v*' --sort=-version:refname) - - expected_assets="$(printf '%s\n' \ - checksums.txt \ - one-cli_darwin_amd64.tar.gz \ - one-cli_darwin_arm64.tar.gz \ - one-cli_linux_amd64.tar.gz \ - one-cli_linux_arm64.tar.gz \ - one-cli_windows_amd64.zip | sort)" - mode="bump" - latest_tag_object="" - decision="bump $BUMP_TYPE from no tag" - - if [[ -n "$latest_tag" ]]; then - latest_sha="$(git rev-list -n 1 "$latest_tag")" - latest_tag_object="$(git rev-parse "$latest_tag")" - latest_on_master=false - git merge-base --is-ancestor "$latest_sha" origin/master && latest_on_master=true - - releases_json="$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/releases?per_page=100")" - latest_release="$(jq -c --arg tag "$latest_tag" \ - '[.[][] | select(.tag_name == $tag)][0] // empty' <<< "$releases_json")" - - if [[ -z "$latest_release" ]]; then - if [[ "$latest_on_master" == "true" ]]; then - mode="resume" - decision="resume $latest_tag (release missing)" - else - echo "Highest stable tag $latest_tag is off master and has no release; clean it up first." >&2 - exit 1 - fi - elif [[ "$(jq -r '.prerelease' <<< "$latest_release")" == "true" ]]; then - echo "Stable tag $latest_tag is marked as a prerelease; resolve that state first." >&2 - exit 1 - elif [[ "$(jq -r '.draft' <<< "$latest_release")" == "true" ]]; then - if [[ "$latest_on_master" == "true" ]]; then - mode="resume" - decision="resume draft $latest_tag" - else - decision="bump $BUMP_TYPE from off-master draft $latest_tag" - fi - else - actual_assets="$(jq -r '.assets[].name' <<< "$latest_release" | sort)" - if [[ "$actual_assets" != "$expected_assets" ]]; then - echo "Published release $latest_tag has an incomplete or unexpected asset set." >&2 - diff -u <(printf '%s\n' "$expected_assets") <(printf '%s\n' "$actual_assets") || true - exit 1 - fi - if [[ "$latest_on_master" == "true" && "$latest_sha" == "$(git rev-parse HEAD)" ]]; then - mode="idempotent" - decision="current commit already published as $latest_tag" - else - decision="bump $BUMP_TYPE from $latest_tag" - fi - fi - fi - - if [[ "$mode" == "resume" || "$mode" == "idempotent" ]]; then - release_version="${latest_tag#v}" - else - base_version="${latest_tag#v}" - [[ -n "$base_version" ]] || base_version="0.0.0" - IFS=. read -r major minor patch <<< "$base_version" - case "$BUMP_TYPE" in - patch) patch=$((patch + 1)) ;; - minor) minor=$((minor + 1)); patch=0 ;; - major) major=$((major + 1)); minor=0; patch=0 ;; - *) echo "Unsupported bump type: $BUMP_TYPE" >&2; exit 1 ;; - esac - release_version="${major}.${minor}.${patch}" - fi - - echo "version=$release_version" >> "$GITHUB_OUTPUT" - echo "base_tag=$latest_tag" >> "$GITHUB_OUTPUT" - echo "base_tag_object=$latest_tag_object" >> "$GITHUB_OUTPUT" - echo "mode=$mode" >> "$GITHUB_OUTPUT" - echo "RELEASE_VERSION=$release_version" >> "$GITHUB_ENV" - { - echo "### Calculated version" - echo "- Version: $release_version" - echo "- Mode: $mode" - echo "- Decision: $decision" - } >> "$GITHUB_STEP_SUMMARY" - - - name: Resolve release state - id: release - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - tag="v$RELEASE_VERSION" - source_sha="$(git rev-parse HEAD)" - tag_exists=false - already_published=false - expected_assets="$(printf '%s\n' \ - checksums.txt \ - one-cli_darwin_amd64.tar.gz \ - one-cli_darwin_arm64.tar.gz \ - one-cli_linux_amd64.tar.gz \ - one-cli_linux_arm64.tar.gz \ - one-cli_windows_amd64.zip | sort)" - releases_json="$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/releases?per_page=100")" - target_release="$(jq -c --arg tag "$tag" \ - '[.[][] | select(.tag_name == $tag)][0] // empty' <<< "$releases_json")" - - if git show-ref --verify --quiet "refs/tags/$tag"; then - tag_exists=true - source_sha="$(git rev-list -n 1 "$tag")" - if ! git merge-base --is-ancestor "$source_sha" origin/master; then - echo "Tag $tag does not point to a commit on master." >&2 - exit 1 - fi - - if [[ -n "$target_release" ]]; then - if [[ "$(jq -r '.prerelease' <<< "$target_release")" == "true" ]]; then - echo "Stable tag $tag is marked as a prerelease." >&2 - exit 1 - fi - if [[ "$(jq -r '.draft' <<< "$target_release")" == "false" ]]; then - actual_assets="$(jq -r '.assets[].name' <<< "$target_release" | sort)" - if [[ "$actual_assets" != "$expected_assets" ]]; then - echo "Published release $tag has an incomplete or unexpected asset set." >&2 - diff -u <(printf '%s\n' "$expected_assets") <(printf '%s\n' "$actual_assets") || true - exit 1 - fi - already_published=true - fi - fi - else - if [[ -n "$target_release" ]]; then - echo "Release $tag exists without a matching Git tag." >&2 - exit 1 - fi - - existing_stable_tags="$(git tag --points-at "$source_sha" | sed -n -E '/^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/p')" - if [[ -n "$existing_stable_tags" ]]; then - echo "This commit already has a stable tag: $existing_stable_tags" >&2 - exit 1 - fi - - latest_published_tag="" - while IFS= read -r candidate; do - [[ "$candidate" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || continue - candidate_release="$(jq -c --arg tag "$candidate" \ - '[.[][] | select(.tag_name == $tag)][0] // empty' <<< "$releases_json")" - if [[ -n "$candidate_release" ]]; then - if [[ "$(jq -r '[.draft, .prerelease] | @tsv' <<< "$candidate_release")" == $'false\tfalse' ]]; then - latest_published_tag="$candidate" - break - fi - fi - done < <(git tag --list 'v*' --sort=-version:refname) - - if [[ -n "$latest_published_tag" ]]; then - latest_version="${latest_published_tag#v}" - highest="$(printf '%s\n%s\n' "$latest_version" "$RELEASE_VERSION" | sort -V | tail -1)" - if [[ "$highest" != "$RELEASE_VERSION" || "$latest_version" == "$RELEASE_VERSION" ]]; then - echo "version must be newer than the latest published release $latest_published_tag." >&2 - exit 1 - fi - fi - fi - - echo "tag=$tag" >> "$GITHUB_OUTPUT" - echo "sha=$source_sha" >> "$GITHUB_OUTPUT" - echo "tag_exists=$tag_exists" >> "$GITHUB_OUTPUT" - echo "already_published=$already_published" >> "$GITHUB_OUTPUT" - - { - echo "### Release state" - echo "- Version: $RELEASE_VERSION" - echo "- Tag: $tag" - echo "- Commit: $source_sha" - echo "- Existing tag: $tag_exists" - echo "- Already published: $already_published" - } >> "$GITHUB_STEP_SUMMARY" - - name: Preserve release configuration - if: steps.release.outputs.already_published != 'true' - run: cp .goreleaser.yaml "$RUNNER_TEMP/one-cli-goreleaser.yaml" - - - name: Check out release commit - if: steps.release.outputs.already_published != 'true' - env: - RELEASE_SHA: ${{ steps.release.outputs.sha }} - run: git checkout --detach "$RELEASE_SHA" - - - name: Set up mise - if: steps.release.outputs.already_published != 'true' - uses: jdx/mise-action@v4 - with: - version: 2026.9.7 - experimental: false - - - name: Download Go modules without mutating the workspace - if: steps.release.outputs.already_published != 'true' - run: | - GOWORK=off go -C packages/kernel mod download - GOWORK=off go -C packages/cli mod download - - - name: Verify dependency metadata is clean - if: steps.release.outputs.already_published != 'true' - run: | - set -euo pipefail - dependency_files=( - go.work - go.work.sum - packages/kernel/go.mod - packages/kernel/go.sum - packages/cli/go.mod - packages/cli/go.sum - ) - changes="$(git status --short -- "${dependency_files[@]}")" - if [[ -n "$changes" ]]; then - echo "Dependency setup changed dependency metadata." >&2 - printf '%s\n' "$changes" >&2 - git diff -- "${dependency_files[@]}" - exit 1 - fi - - - name: Run complete release gate - if: steps.release.outputs.already_published != 'true' - # The release gate combines jobs that PR CI runs on separate runners. - # Keep cold Go builds/vet/race tests from starving jsdom interactions. - run: mise run --jobs 1 pre-push - - - name: Verify release tree is clean - if: steps.release.outputs.already_published != 'true' - run: | - set -euo pipefail - git diff --exit-code - if [[ -n "$(git status --porcelain --untracked-files=no)" ]]; then - git status --short - echo "Release checkout has tracked changes." >&2 - exit 1 - fi - - - name: Create local release tag - if: steps.release.outputs.already_published != 'true' - env: - TAG: ${{ steps.release.outputs.tag }} - RELEASE_SHA: ${{ steps.release.outputs.sha }} - TAG_EXISTS: ${{ steps.release.outputs.tag_exists }} - run: | - set -euo pipefail - if [[ "$TAG_EXISTS" == "true" ]]; then - actual_sha="$(git rev-list -n 1 "$TAG")" - if [[ "$actual_sha" != "$RELEASE_SHA" ]]; then - echo "Tag $TAG points to $actual_sha, expected $RELEASE_SHA." >&2 - exit 1 - fi - else - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git tag -a "$TAG" "$RELEASE_SHA" -m "$TAG" - fi - - - name: Run GoReleaser preflight build - if: steps.release.outputs.already_published != 'true' - uses: goreleaser/goreleaser-action@v7 - with: - version: v2.18.0 - args: release --config ${{ runner.temp }}/one-cli-goreleaser.yaml --skip=publish --clean - env: - GORELEASER_CURRENT_TAG: ${{ steps.release.outputs.tag }} - - - name: Verify packaged CLI version - if: steps.release.outputs.already_published != 'true' - run: | - set -euo pipefail - archive="dist/one-cli_linux_amd64.tar.gz" - extract_dir="$RUNNER_TEMP/one-cli-version-check" - test -f "$archive" - mkdir -p "$extract_dir" - tar -xzf "$archive" -C "$extract_dir" - actual_version="$("$extract_dir/one" --version | tr -d '[:space:]')" - if [[ "$actual_version" != "$RELEASE_VERSION" ]]; then - echo "Packaged CLI reports $actual_version, expected $RELEASE_VERSION." >&2 - exit 1 - fi - - - name: Revalidate release state - if: steps.release.outputs.already_published != 'true' - env: - BASE_TAG: ${{ steps.version.outputs.base_tag }} - BASE_TAG_OBJECT: ${{ steps.version.outputs.base_tag_object }} - GH_TOKEN: ${{ github.token }} - MODE: ${{ steps.version.outputs.mode }} - RELEASE_SHA: ${{ steps.release.outputs.sha }} - TAG: ${{ steps.release.outputs.tag }} - run: | - set -euo pipefail - remote_latest_tag="$(git ls-remote --refs --tags origin 'refs/tags/v*' | - awk '{sub("refs/tags/", "", $2); print $2}' | - sed -n -E '/^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/p' | - sort -V | tail -n 1)" - if [[ "$remote_latest_tag" != "$BASE_TAG" ]]; then - echo "Highest stable tag changed from $BASE_TAG to $remote_latest_tag; restart the release." >&2 - exit 1 - fi - if [[ -n "$BASE_TAG" ]]; then - remote_base_object="$(git ls-remote --refs --tags origin "refs/tags/$BASE_TAG" | awk 'NR == 1 {print $1}')" - if [[ "$remote_base_object" != "$BASE_TAG_OBJECT" ]]; then - echo "Base tag $BASE_TAG moved during this run; restart the release." >&2 - exit 1 - fi - fi - - releases_json="$(gh api --paginate --slurp "repos/${GITHUB_REPOSITORY}/releases?per_page=100")" - target_release="$(jq -c --arg tag "$TAG" \ - '[.[][] | select(.tag_name == $tag)][0] // empty' <<< "$releases_json")" - case "$MODE" in - bump) - current_master="$(git ls-remote origin 'refs/heads/master' | awk 'NR == 1 {print $1}')" - if [[ "$current_master" != "$RELEASE_SHA" ]]; then - echo "master advanced to $current_master after validation; restart the release." >&2 - exit 1 - fi - if git ls-remote --exit-code --refs --tags origin "refs/tags/$TAG" >/dev/null 2>&1 || - [[ -n "$target_release" ]]; then - echo "Tag or release $TAG appeared during this run; restart to validate it." >&2 - exit 1 - fi - ;; - resume) - if [[ -n "$target_release" ]]; then - if [[ "$(jq -r '.prerelease' <<< "$target_release")" == "true" || - "$(jq -r '.draft' <<< "$target_release")" != "true" ]]; then - echo "Release $TAG changed while validation was running; restart the release." >&2 - exit 1 - fi - fi - ;; - *) - echo "Unexpected release mode: $MODE" >&2 - exit 1 - ;; - esac - - - name: Push release tag - if: steps.release.outputs.already_published != 'true' && steps.release.outputs.tag_exists != 'true' - env: - BASE_TAG: ${{ steps.version.outputs.base_tag }} - BASE_TAG_OBJECT: ${{ steps.version.outputs.base_tag_object }} - GH_TOKEN: ${{ github.token }} - TAG: ${{ steps.release.outputs.tag }} - RELEASE_SHA: ${{ steps.release.outputs.sha }} - run: | - set -euo pipefail - git fetch origin master --tags - current_master="$(git rev-parse origin/master)" - if [[ "$current_master" != "$RELEASE_SHA" ]]; then - echo "master advanced to $current_master after validation; restart the release." >&2 - exit 1 - fi - - remote_latest_tag="$(git ls-remote --refs --tags origin 'refs/tags/v*' | - awk '{sub("refs/tags/", "", $2); print $2}' | - sed -n -E '/^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/p' | - sort -V | tail -n 1)" - if [[ "$remote_latest_tag" != "$BASE_TAG" ]]; then - echo "Highest stable tag changed from $BASE_TAG to $remote_latest_tag; restart the release." >&2 - exit 1 - fi - if [[ -n "$BASE_TAG" ]]; then - remote_base_object="$(git ls-remote --refs --tags origin "refs/tags/$BASE_TAG" | awk 'NR == 1 {print $1}')" - if [[ "$remote_base_object" != "$BASE_TAG_OBJECT" ]]; then - echo "Base tag $BASE_TAG moved during this run; restart the release." >&2 - exit 1 - fi - fi - - if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then - echo "Remote tag $TAG appeared during this run; restart to validate it." >&2 - exit 1 - fi - gh auth setup-git - git push origin "refs/tags/$TAG" - - - name: Run GoReleaser - if: steps.release.outputs.already_published != 'true' - uses: goreleaser/goreleaser-action@v7 - with: - version: v2.18.0 - args: release --config ${{ runner.temp }}/one-cli-goreleaser.yaml --clean - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GORELEASER_CURRENT_TAG: ${{ steps.release.outputs.tag }} - - - name: Verify draft release assets - if: steps.release.outputs.already_published != 'true' - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ steps.release.outputs.tag }} - run: | - set -euo pipefail - is_draft="$(gh release view "$TAG" --json isDraft --jq '.isDraft')" - if [[ "$is_draft" != "true" ]]; then - echo "Release $TAG must remain a draft until its assets are verified." >&2 - exit 1 - fi - - expected_assets="$(printf '%s\n' \ - checksums.txt \ - one-cli_darwin_amd64.tar.gz \ - one-cli_darwin_arm64.tar.gz \ - one-cli_linux_amd64.tar.gz \ - one-cli_linux_arm64.tar.gz \ - one-cli_windows_amd64.zip | sort)" - actual_assets="$(gh release view "$TAG" --json assets --jq '.assets[].name' | sort)" - if [[ "$actual_assets" != "$expected_assets" ]]; then - echo "Draft release $TAG has an incomplete or unexpected asset set." >&2 - diff -u <(printf '%s\n' "$expected_assets") <(printf '%s\n' "$actual_assets") || true - exit 1 - fi - - - name: Publish GitHub release - if: steps.release.outputs.already_published != 'true' - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ steps.release.outputs.tag }} - run: | - set -euo pipefail - gh release edit "$TAG" --draft=false --latest - is_draft="$(gh release view "$TAG" --json isDraft --jq '.isDraft')" - if [[ "$is_draft" != "false" ]]; then - echo "Release $TAG is still a draft after publishing." >&2 - exit 1 - fi - - - name: Summarize release - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ steps.release.outputs.tag }} - ALREADY_PUBLISHED: ${{ steps.release.outputs.already_published }} - run: | - set -euo pipefail - release_url="$(gh release view "$TAG" --json url --jq '.url')" - { - echo "### Published" - echo "- Version: $RELEASE_VERSION" - echo "- Tag: $TAG" - echo "- Release: $release_url" - echo "- Already complete before this run: $ALREADY_PUBLISHED" - } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml deleted file mode 100644 index bf736034..00000000 --- a/.github/workflows/docs.yml +++ /dev/null @@ -1,45 +0,0 @@ -# Build the docs site for PR/push confidence. Production deploys are handled -# by the Vercel project linked to apps/docs and the 1cli.dev domain. - -name: Documentation Build - -on: - pull_request: - paths: - - "apps/docs/**" - - "package.json" - - "pnpm-lock.yaml" - - "pnpm-workspace.yaml" - - "mise.toml" - - "packages/templates/registry.json" - - ".github/workflows/docs.yml" - push: - branches: [master] - paths: - - "apps/docs/**" - - "package.json" - - "pnpm-lock.yaml" - - "pnpm-workspace.yaml" - - "mise.toml" - - "packages/templates/registry.json" - - ".github/workflows/docs.yml" - workflow_dispatch: - -permissions: - contents: read - -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Check out - uses: actions/checkout@v7 - - - name: Set up mise - uses: jdx/mise-action@v4 - with: - version: 2026.9.7 - experimental: false - - - name: Build docs site - run: mise run docs:build diff --git a/.gitignore b/.gitignore index 17aba7f8..1bc3cbff 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ node_modules/ dist/**/* -coverage/ *.log .DS_Store .env @@ -18,8 +17,6 @@ tmp bin/ go-dist/ *.out -*.test -.golangci-cache/ # seo-cli local state .seo/ @@ -27,7 +24,7 @@ go-dist/ # Bundled go:embed sources — regenerated by `mise run sync-bundled` (cheap # cp from packages/templates/) and `mise run sync-web` # (pnpm install + vite build of apps/dashboard/). Both run as deps of -# `mise run vet` / `test` / `build`, so the normal mise flow always +# `mise run vet` / `build`, so the normal mise flow always # repopulates them. Fresh clone followed by `go build` directly will # fail until one of those tasks has run at least once. packages/cli/internal/resources/bundled/registry.json diff --git a/.goreleaser.yaml b/.goreleaser.yaml deleted file mode 100644 index dc597834..00000000 --- a/.goreleaser.yaml +++ /dev/null @@ -1,96 +0,0 @@ -# goreleaser config for the Go rewrite of one-cli. -# -# Cuts cross-platform binaries on every git tag, packs them into per-target -# tarballs/zips, and uploads them to a GitHub Release. install.sh downloads -# the matching tarball from those release assets at install time. -# -# Run locally: goreleaser release --snapshot --clean -# Run on tag: goreleaser release --clean (CI / release pipeline) -# -# The release workflow computes RELEASE_VERSION and creates its local tag. -# GoReleaser derives .Version from that tag and injects it through -# ldflags, so no tracked version file needs to change before a release. - -version: 2 - -project_name: one-cli - -builds: - - id: one - dir: packages/cli - main: ./cmd/one - binary: one - env: - - CGO_ENABLED=0 - goos: - - darwin - - linux - - windows - goarch: - - amd64 - - arm64 - # No need for windows/arm64 yet — too few users. - ignore: - - goos: windows - goarch: arm64 - ldflags: - - -s -w - - -X main.version={{.Version}} - - -X github.com/torchstellar-team/one-cli/packages/cli/internal/platform/updatecheck.buildChannel=release - # Published releases restore One skills from this exact source commit. - # Local snapshots retain their embedded, unpublished skill files. - - -X github.com/torchstellar-team/one-cli/packages/cli/internal/modules/skills.bundledSourceRef={{ if not .IsSnapshot }}{{ .FullCommit }}{{ end }} - flags: - - -trimpath - -archives: - - id: default - formats: - - tar.gz - format_overrides: - - goos: windows - formats: - - zip - # install.sh resolves binaries by archive name. Keep the template shape - # (one-cli_{os}_{arch}) stable so older installers keep working when we - # cut newer releases. - name_template: "one-cli_{{ .Os }}_{{ .Arch }}" - files: - - README* - - third_party/mise/LICENSE - -checksum: - name_template: "checksums.txt" - algorithm: sha256 - -snapshot: - version_template: "{{ incpatch .Version }}-snapshot" - -changelog: - # We keep CHANGELOG.md authoritative. goreleaser's auto-generated entries - # are noisy and conflict with the curated narrative. - disable: true - -release: - # install.sh downloads from /releases/download// so the - # exact GitHub repo coordinates here matter. Adjust if the repo moves. - github: - owner: 1cli-team - name: one-cli - draft: true - # A failed publish may already have created the tag and part of its draft. - # Reuse that draft and replace duplicate assets so the publish operation is - # safe to retry instead of silently skipping to the next version. - use_existing_draft: true - replace_existing_artifacts: true - make_latest: true - prerelease: auto - header: | - # one-cli {{ .Tag }} - - Pre-built binaries for darwin/linux/windows × amd64/arm64. - Install via the curl one-liner (see README), or download the - matching archive below and extract `one` onto your PATH. - - footer: | - Verify your download with `sha256sum -c checksums.txt`. diff --git a/AGENTS.md b/AGENTS.md index 532e70ea..fd277a90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,4 +41,4 @@ - 两份语言字典的键必须完整对应、译文非空、格式占位符兼容。新增翻译键时确认实际使用处能够解析。 - 根据改动覆盖两种语言的帮助、交互、错误或结果输出,并检查语言切换后没有旧语言残留。涉及持久化偏好的测试使用临时 HOME/config,避免修改开发者设置。 - 修改提示组件或 TUI 时,检查中文终端显示宽度、快捷键提示和文字对比度,并确认子进程原始输出仍被保留。 -- 文案变化需要审阅并更新相关帮助/输出快照;运行与改动相关的测试,并按仓库现有流程执行 `mise run check`。 +- 文案变化需要审阅相关帮助和输出;运行与改动相关的构建及静态检查,并按仓库现有流程执行 `mise run check`。 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 823741c1..1ba8fd4e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -129,8 +129,6 @@ packages/cli/internal/ packages/cli/ cmd/one/ thin executable entry point pkg/ intentionally public Go packages - testdata/ stable fixtures and compatibility snapshots - tests/e2e/ binary and full-command contract tests tools/ repository verification programs apps/dashboard/src/ @@ -173,13 +171,11 @@ Rules: reversible transaction. 10. Command, runtime, toolchain, and secrets provider sets are constructed explicitly. Provider packages do not register themselves through `init()`. -11. `internal/architecture/dependencies_test.go` enforces these boundaries for - production Go files, including leaf-layer and transport/adapter rules. -12. `packages/kernel` imports only the Go standard library. It never owns +11. `packages/kernel` imports only the Go standard library. It never owns Workspace, Project, Environment, Backend, Profile, or Template policy. -13. Workspace modules are listed explicitly in `go.work`; embedded Go template +12. Workspace modules are listed explicitly in `go.work`; embedded Go template modules under `packages/templates` remain standalone template fixtures. -14. `packages/kernel` follows the applicable parts of the Go project-layout +13. `packages/kernel` follows the applicable parts of the Go project-layout convention: public APIs live under `pkg/kernel`, private implementation lives under `internal`, and design notes live under `docs`. Executable and deployment directories are intentionally absent because Kernel is a @@ -304,7 +300,7 @@ Finite workspace tasks are owned by `modules/tasks` and `modules/miseconfig`: - Hidden task adapters consume the same temporary context for cache fingerprints and execution. Context values never enter generated configuration or result envelopes. - mise owns task concurrency and artifact storage. One reports overall success/failure and leaves unsupported per-task event state unknown. - `one build` and `one run build` share this implementation. `one exec` handles arbitrary commands, while development keeps the existing terminal supervisor after finite prerequisite builds. -- GitHub Actions files stay repository-owned and call the ordinary `ci` aggregate. Hooks remain supported. +- Hooks remain optional and are configured explicitly with `one init hooks`. Environment is a vertical deep module because its two built-in backends are compiled implementation components rather than independently distributed @@ -393,14 +389,12 @@ Dashboard dependencies point inward: `router` composes `pages`, pages compose features, and features may use API wrappers and shared UI primitives. Only the router imports routed pages; features never import pages or the router. API wrappers do not import presentation code, and `components/ui` remains a leaf -view layer. `src/architecture/dependencies.test.ts` enforces these rules and -rejects local TypeScript barrel entrypoints so imports stay directly -analyzable. +view layer. Local TypeScript imports use direct module paths rather than barrel +entrypoints so dependencies stay directly analyzable. Repository verification has one public contract: `mise run check` (also exposed as -root `pnpm check`). It composes `check:static` and `check:test`; CI runs those -same two subtasks in parallel. `mise run pre-push` adds Go race detection without -creating a separate definition of the PR gate. +root `pnpm check`). It runs `check:static` for documentation, Go vet and +formatting, and Dashboard lint and formatting. Node dependency resolution is likewise repository-owned: root `pnpm-workspace.yaml`, `package.json`, and `pnpm-lock.yaml` are the only diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a7fcf562..f5773219 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,7 +18,7 @@ one --version # 验证装好 > **fresh-clone 提示**:`packages/cli/internal/resources/bundled/` 整个目录是 gitignore 的—— > registry / templates / dashboard dist 都由 `mise run sync-bundled` + -> `mise run sync-web` 按需重建。这些任务作为 `mise run vet` / `test` / `build` 的依赖自动运行; +> `mise run sync-web` 按需重建。这些任务作为 `mise run vet` / `build` 的依赖自动运行; > One 使用官方 Process Compose;贡献者先安装 mise,再使用根任务准备和构建资源。 > 第一次 `mise run install` 会准备依赖并构建 Dashboard;之后输入未变时复用 Dashboard 产物缓存。 > 如果你直接跑 `go build` 而不走 mise, @@ -36,7 +36,7 @@ one --version # 验证装好 | `cli` | `packages/cli` | Go CLI 和公开 Go 包 | | `kernel` | `packages/kernel` | 共享 Go 内核 | -`packages/templates` 和测试 fixture 是源码素材,不登记为项目。`packages/cli` 保留现有路径,因为公开 Go 包的 module path 已包含该目录。 +`packages/templates` 是源码素材,不登记为项目。`packages/cli` 保留现有路径,因为公开 Go 包的 module path 已包含该目录。 已有 checkout 升级后也先运行 `mise run install`,重新构建本地新版并将 `one` 启动器指向 `packages/cli/bin/one`,避免继续调用旧发行版。 @@ -48,63 +48,41 @@ one run # 查看根任务和项目任务 one run dev # Go Dashboard API + Vite UI,管理当前真实工作区 one run dev -p docs # 文档站:http://localhost:3000 one run build -p cli # 准备嵌入资源并构建 CLI -one run test -p kernel # 测试共享 Go 内核 one run check # 完整仓库检查;也可简写 one check one serve # 在 Dashboard 中管理当前真实仓库 ``` -单独运行 `one run dev -p dashboard` 只启动 Vite 前端,需要在另一终端运行 `one run dev -p cli` 提供 API;通常直接使用联合任务 `one run dev`。开发 API 与 `one serve` 都管理当前真实仓库。构建、测试和开发不需要绑定 Infisical。 +单独运行 `one run dev -p dashboard` 只启动 Vite 前端,需要在另一终端运行 `one run dev -p cli` 提供 API;通常直接使用联合任务 `one run dev`。开发 API 与 `one serve` 都管理当前真实仓库。构建、静态检查和开发不需要绑定 Infisical。 `one run dev --ui tui` 使用 One 的运行中列表、完整任务树和日志界面,官方 Process Compose 以 headless 模式调度;`--ui stream` 使用流式输出。Tab 切换焦点,方向键展开/定位;拖选后 y 复制,c 关闭鼠标报告并固定全宽日志供终端原生复制,Esc 返回。无需声明 service。状态来自实际命令开始/结束事件,不从输出猜测。多任务交互终端默认 TUI,CI 和结构化输出使用 stream;全部完成后自动关闭,Ctrl+C 取消并恢复终端。完整日志保存在私有临时 journal,不受上游 10,000 行缓存限制。 mise 安装固定的 Process Compose 1.122.0,One 生成每次运行的私有配置;变量通过内存认证通道传给叶子进程。无需 Rust、补丁或嵌入调度器构建。首版仅支持静态任务子集,能力和限制见 [one run 文档](apps/docs/content/docs/zh/run.md)。Process Compose 没有全局并发上限,不再承诺任意 `--concurrency` 生效。 -根 `mise.toml` 继续维护原生任务,供首次安装、CI 和 Git hooks 使用: +根 `mise.toml` 继续维护原生任务,供首次安装和本地检查使用: ```bash mise run install-deps # 安装锁定的 Node workspace 依赖 -mise run check # 当前平台的静态检查、Go 和 Dashboard 测试 +mise run check # Go、Dashboard 和文档静态检查 mise run build # 编译到 packages/cli/bin/one -mise run test # Go race 测试 + Dashboard 测试 mise run install # 打包并安装本地启动器,无需预先安装 one -mise run pre-push # 推送前检查,包含 race 测试和模板构建 ``` -项目任务来自各自的 `package.json` 或 `Taskfile.yml`,只在根 `mise.toml` 登记 `cli:build`、`dashboard:dev` 这样的任务入口,并通过 `dir` 指定子项目目录。根文件也声明 Dashboard、模板和嵌入资源的前置步骤,CLI 测试会先构建 E2E 使用的二进制。新增或修改任务目录后,运行 `one init mise` 同步这些受版本控制的原生任务。执行和查询不会重新生成配置;Dashboard 从进程输出发现访问地址,dev 命令在 mise.toml 中定义,由 Process Compose 执行。 +项目任务来自各自的 `package.json` 或 `Taskfile.yml`,只在根 `mise.toml` 登记 `cli:build`、`dashboard:dev` 这样的任务入口,并通过 `dir` 指定子项目目录。根文件也声明 Dashboard、模板和嵌入资源的前置步骤。新增或修改任务目录后,运行 `one init mise` 同步这些受版本控制的原生任务。执行和查询不会重新生成配置;Dashboard 从进程输出发现访问地址,dev 命令在 mise.toml 中定义,由 Process Compose 执行。 ## 提交流程 -1. 起一个分支:`git checkout -b feat/` 或 `fix/` -2. 改代码 + 测试 +1. 起一个分支:`git checkout -b feat/` 或 `fix/`。 +2. 改代码,运行 `mise run check` 和相关构建。 3. 提交:commit 消息走 [conventional commits](https://www.conventionalcommits.org/) - (`feat:`、`fix:`、`chore:`、`docs:`、`test:`、`refactor:` 等)。仓库的 - pre-commit hook 会自动运行当前平台的 `mise run check`,覆盖静态检查、普通 Go 测试 - 和 Dashboard 测试;如果当前 checkout 尚未启用 hook,先运行 `mise run hooks:install`。 - 该命令同时启用 pre-commit 和 pre-push。pre-commit 会拒绝混合已暂存、未暂存或未跟踪的 - 文件,确保本地检查的内容与即将提交、随后由 CI 检查的快照一致 -4. 推送:pre-push hook 会自动运行 `mise run --jobs 1 pre-push`,包含 `check`、 - Go race detector 和模板源码/生成项目构建,检查失败会阻止推送。也可提前运行 - `mise run --jobs 1 pre-push`;模板构建需要下载依赖,因此比提交检查耗时更长。 - pre-push 要求工作区干净,避免用未提交的修复验证将要推送的旧提交 -5. 开 PR,等待各平台 CI 验证 - -PR CI 在 Linux 上并行执行 `mise run check:static`、`mise run check:test` 与 -`mise run test:go`(Go race detector);Linux 的 test job 还执行 `check:templates`。 -同时在 Windows 上执行 `mise run check`、 -macOS 上执行 `mise run check:test`。master 的保护规则要求 `lint`、`test`、 -`test-windows`、`test-macos`、`test-race` 五项检查全部通过才能合并。 -本地 `mise run check` 和 `mise run pre-push` 只验证当前操作系统,不能代替其他平台的 -CI;`mise run pre-push` 额外运行 Go race detector 和模板构建。Windows/macOS 的原生 -运行不能由 Linux 交叉编译代替。路径相关测试应覆盖符号链接、规范化路径,以及 -同一文件的不同路径写法,避免只在 Linux 上通过。远端五项检查会在 PR、master -推送和手动触发的工作流中运行。 + (`feat:`、`fix:`、`chore:`、`docs:`、`refactor:` 等)。 +4. 推送并开 PR,附上本地验证结果。 ## 改不同部分的注意事项 ### 改 Go 代码(`packages/cli/internal/` / `packages/cli/pkg/`) - 公开 API(`packages/cli/pkg/`)改动要考虑 semver;详见 [CLAUDE.md 的 Public API stability](./CLAUDE.md) -- 加新错误码:在 `packages/cli/internal/platform/errors/codes.go` 注册 `Code` 常量 + `Codes` map 条目;测试会强制对应;改完跑 `mise run gen-error-codes` 刷新文档 +- 加新错误码:在 `packages/cli/internal/platform/errors/codes.go` 注册 `Code` 常量 + `Codes` map 条目;改完跑 `mise run gen-error-codes` 刷新文档 ### mise 运行时 @@ -114,8 +92,6 @@ CI;`mise run pre-push` 额外运行 Go race detector 和模板构建。Windows 升级时验证上游校验文件的签名,更新 `packages/cli/internal/adapters/runtime/mise/miserelease/release.go` 中的版本、压缩包和程序 SHA256,再更新需要提高的 runtime 最低版本及相关文档。托管版本随 One 更新,不通过 `mise self-update` 维护。上游许可证保留于 `third_party/mise/LICENSE` 和 One 发布归档。 -下载器及安装器测试使用本地 HTTP fixture,覆盖并发、重试、取消、摘要和删除修复。`ONE_TEST_MISE_BINARY=/absolute/path/to/mise go test ./packages/cli/tests/e2e -run Mise` 启用真实配置与信任测试;其中托管迁移测试要求与仓库固定摘要一致的官方程序。真实下载和多平台冒烟验证在发布前单独执行,不作为普通构建的资源依赖。 - ### 改 templates(`packages/templates//`) - 模板会被 `go:embed` 进二进制(`mise run sync-bundled` 是同步入口,自动跑) @@ -127,57 +103,26 @@ CI;`mise run pre-push` 额外运行 Go race detector 和模板构建。Windows - 前后端联调:在仓库根目录运行 `one run dev`,打开 `http://localhost:5173/` - 开发 API 使用当前仓库的真实项目与任务;页面保存项目配置会更新当前仓库的 manifest,账号使用本机 One 登录会话 - 本地开发:先在仓库根目录运行 `pnpm install`,再运行 `pnpm --filter one-serve-web dev` -- 静态检查:`mise run check:dashboard`;架构护栏和交互测试包含在 `mise run check:test` -- 改完后 `mise run vet` / `test` / `build` 会自动跑 `sync-web`(pnpm install + vite build) +- 静态检查:`mise run check:dashboard`;完整仓库检查:`mise run check` +- 改完后 `mise run vet` / `build` 会自动跑 `sync-web`(pnpm install + vite build) 并刷 `packages/cli/internal/resources/bundled/_web/` ### 改文档站(`apps/docs/`) - 文档站是 Next.js + Fumadocs SSG - 本地预览:在仓库根目录运行 `one run dev -p docs` -- 线上部署:Vercel 项目 Root Directory 指向 `apps/docs`,Output Directory 用 `dist`,域名绑定 `1cli.dev` - `apps/docs/content/docs/reference/error-codes.md` **不要手工编辑**——跑 `mise run gen-error-codes` 重生成 - 新增页面要更新对应目录的 `meta.json`(sidebar 顺序) ### 改 install.sh(`apps/docs/public/install.sh`) -- 改完跑 `mise run test` —— `packages/cli/tests/e2e/install_sh_test.go` 会做静态检查(语法、必要 sentinels、wrap-in-main 不变量) - -## 测试约定 - -```bash -mise run test # 默认全套 -(cd packages/cli && go test ./internal/foo) # 单个包 -(cd packages/cli && go test -run TestX ./...) # 单个 test -(cd packages/cli && UPDATE_SNAPSHOTS=1 go test ./tests/e2e) # 重生成 e2e snapshot fixtures -``` - -E2E snapshot 测试位于 `packages/cli/tests/e2e/snapshot_e2e_*_test.go`,依赖 `packages/cli/bin/one` 存在 —— 跑 `mise run build` 之后再跑。 - -## 发布流程 - -发布统一从 GitHub Actions 的 **Build and Release** 手动触发: - -1. 在 `master` 上运行工作流,选择 `patch`(默认)、`minor` 或 `major`。 -2. 工作流从最高稳定 tag 自动计算下一版本,并把结果作为 `RELEASE_VERSION`;若最高 tag 位于 `master` 且尚未完成发布,则优先续跑该版本。 -3. 工作流执行完整 `mise run pre-push` 和 GoReleaser no-publish 预构建;验证通过后创建计算出的 tag,生成 5 个平台归档及 `checksums.txt`。 -4. 只有 asset 集合完整时,GitHub Release 才会从 draft 转为公开发布。 - -不需要为了发布修改或提交任何版本文件,也不要手工推 tag。若发布在 tag 或 draft 创建后失败,修复问题后重新运行;工作流会自动识别安全的未完成 tag 并复用 draft。已经完整发布的版本不会被重复发布。 - -发布 channel: - -- **GitHub Releases** — `install.sh` 下载二进制和 `checksums.txt` 的来源 -- **Vercel** `https://1cli.dev` — 文档站和 `install.sh` -- ~~**npm `qzkpwoxtl`**~~ — v0.4.1 起停发 +- 改完运行 `bash -n apps/docs/public/install.sh` 检查脚本语法。 ## 环境变量(开发时常用) | 变量 | 用途 | |---|---| | `ONE_BINARY_PATH` | 让 wrapper / 子 shell 用某个特定 binary | -| `UPDATE_SNAPSHOTS=1` | E2E 测试重写 snapshot | -| `INFISICAL_UNIVERSAL_AUTH_*` | secrets 测试需要(一般 mock,跳过 live) | ## 仓库布局 @@ -188,14 +133,12 @@ packages/cli/ # Go module(module path 含 /packages/cli 后 pkg/ # 公开 Go API(semver 保护) internal/resources/bundled/ # go:embed 镜像,目录整个 gitignore, # 由 mise run sync-bundled + sync-web 重建 - testdata/ # Go 测试 fixtures tools/ # 内部生成器 / 校验器 # gen-error-codes / verify-cli-references / verify-help packages/templates/ # 模板源 + registry.json(被 go:embed) apps/docs/ # 文档站 Next.js + Fumadocs apps/dashboard/ # `one serve` 用的 React + Vite UI(被 go:embed) -.github/workflows/ # ci / cli / docs -mise.toml / .goreleaser.yaml # 顶层编排(路径都按上面这套) +mise.toml # 本地任务编排 pnpm-workspace.yaml # apps/* + packages/* DESIGN.md / apps/docs/design/ # 设计源 ``` diff --git a/README.md b/README.md index 64122634..5c3711aa 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ If you want to work on One CLI itself, the repository is organized like this: | `apps/dashboard` | Local workspace, account, and global-variable Dashboard opened by `one serve` | | `assets` | Brand assets, including the logo | -This repository is also a One CLI workspace: `dashboard`, `docs`, `cli`, and `kernel`. Template directories under `packages/templates` and test fixtures are source assets, not registered projects. `packages/cli` keeps its existing location because it also exports Go packages with that module path. +This repository is also a One CLI workspace: `dashboard`, `docs`, `cli`, and `kernel`. Template directories under `packages/templates` are source assets, not registered projects. `packages/cli` keeps its existing location because it also exports Go packages with that module path. Bootstrap a fresh checkout without requiring an installed `one`: @@ -164,16 +164,15 @@ one run # List root and project tasks one run dev # Dashboard API + Vite UI for this workspace one run dev -p docs # Documentation at http://localhost:3000 one run build -p cli # Prepare embedded resources and build the CLI -one run test -p kernel # Test the shared Go kernel one run check # The complete repository gate; one check is shorthand one serve # Manage this repository in the Dashboard ``` -`one run dev -p dashboard` starts the Vite UI; run `one run dev -p cli` in another terminal for its API, or use the combined `one run dev` task. Both the development API and `one serve` use this repository as their workspace. No Infisical binding is required to build, test, or start these projects. +`one run dev -p dashboard` starts the Vite UI; run `one run dev -p cli` in another terminal for its API, or use the combined `one run dev` task. Both the development API and `one serve` use this repository as their workspace. No Infisical binding is required to build, check, or start these projects. -Root tasks remain native mise commands, so CI and first-time installation can still use `mise run build`, `mise run check`, and `mise run install`. Project tasks come from package scripts and Taskfiles. Only the root `mise.toml` is used: project tasks are namespaced as `cli:build` or `dashboard:dev` and select their directory with `dir`. CLI tasks declare their embedded-resource prerequisites there; CLI tests also build the binary used by E2E tests. Run `one init mise` after changing the project task catalogue to refresh the tracked native tasks. Running or listing tasks does not regenerate configuration. Dashboard discovers access links from process output; dev commands are defined in mise.toml and executed by Process Compose. +Root tasks remain native mise commands, so local checks and first-time installation can use `mise run build`, `mise run check`, and `mise run install`. Project tasks come from package scripts and Taskfiles. Only the root `mise.toml` is used: project tasks are namespaced as `cli:build` or `dashboard:dev` and select their directory with `dir`. CLI tasks declare their embedded-resource prerequisites there. Run `one init mise` after changing the project task catalogue to refresh the tracked native tasks. Running or listing tasks does not regenerate configuration. Dashboard discovers access links from process output; dev commands are defined in mise.toml and executed by Process Compose. -Run `mise run hooks:install` after cloning to enable this repository's Git hooks. Pre-commit runs `mise run check` for static checks, ordinary Go tests, and Dashboard tests on your current platform. Pre-push runs `mise run --jobs 1 pre-push`, adding Go race tests and template source/generated-project builds; failures stop the push. Template builds download dependencies and take longer than commit checks. Native Windows and macOS behavior is verified by the PR's CI jobs, even when local Linux checks pass. +Run `mise run check` for Go vet, formatting, Dashboard lint, and documentation checks. Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening a pull request. diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 1ec7e149..4150b1ec 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -8,8 +8,6 @@ "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", - "test": "vitest run", - "test:watch": "vitest", "lint": "oxlint .", "lint:fix": "oxlint --fix .", "format": "oxfmt --check .", @@ -37,18 +35,13 @@ "zustand": "^5.0.15" }, "devDependencies": { - "@testing-library/react": "^16.3.3", - "@testing-library/user-event": "^14.6.7", "@types/node": "^26.6.3", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.1", - "jsdom": "^30.1.1", - "msw": "^2.15.0", "oxfmt": "^0.70.0", "oxlint": "^1.85.0", "typescript": "^7.0.2", - "vite": "^8.3.1", - "vitest": "^5.0.2" + "vite": "^8.3.1" } } diff --git a/apps/dashboard/src/architecture/dependencies.test.ts b/apps/dashboard/src/architecture/dependencies.test.ts deleted file mode 100644 index 2244b6b4..00000000 --- a/apps/dashboard/src/architecture/dependencies.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; -import { basename, join, relative, sep } from "node:path"; -import { describe, expect, it } from "vitest"; - -const SOURCE_ROOT = join(process.cwd(), "src"); -const SOURCE_EXTENSIONS = new Set([".ts", ".tsx"]); - -interface DependencyRule { - from: string; - forbidden: readonly string[]; -} - -const DEPENDENCY_RULES: readonly DependencyRule[] = [ - { - from: "api", - forbidden: ["components", "features", "hooks", "pages", "providers", "router"], - }, - { - from: "components/ui", - forbidden: ["api", "features", "hooks", "pages", "providers", "router"], - }, - { from: "features", forbidden: ["pages", "router"] }, - { from: "pages", forbidden: ["pages", "router"] }, -]; - -describe("Dashboard dependency boundaries", () => { - it("keeps imports pointing inward", () => { - const violations: string[] = []; - for (const file of productionSourceFiles()) { - const source = sourcePath(file); - const imports = aliasImports(readFileSync(file, "utf8")); - const rule = DEPENDENCY_RULES.find(({ from }) => inArea(source, from)); - - for (const imported of imports) { - const target = imported.slice(2); - if (target.startsWith("pages/") && !inArea(source, "router")) { - violations.push(`${source} imports routed page ${imported}`); - continue; - } - const blocked = rule?.forbidden.find((area) => inArea(target, area)); - if (blocked) { - violations.push(`${source} imports outer area ${imported}`); - } - } - } - - expect(violations, violations.join("\n")).toEqual([]); - }); - - it("uses direct source imports instead of local barrel entrypoints", () => { - const violations: string[] = []; - for (const file of productionSourceFiles()) { - const source = sourcePath(file); - const contents = readFileSync(file, "utf8"); - if (/^index\.tsx?$/.test(basename(file)) && /\bexport\s+(?:\*|\{)/.test(contents)) { - violations.push(`${source} is a local barrel file`); - } - - for (const imported of aliasImports(contents)) { - const target = join(SOURCE_ROOT, imported.slice(2)); - if (existsSync(target) && statSync(target).isDirectory()) { - violations.push(`${source} imports directory barrel ${imported}`); - } - } - } - - expect(violations, violations.join("\n")).toEqual([]); - }); - - it("keeps interactive primitives inside components/ui", () => { - const violations: string[] = []; - const forbidden = [ - { pattern: /" }, - { pattern: /" }, - { pattern: /" }, - { pattern: /" }, - { pattern: /\bwindow\.confirm\s*\(/g, label: "window.confirm" }, - ] as const; - - for (const file of productionSourceFiles()) { - const source = sourcePath(file); - if (inArea(source, "components/ui")) continue; - const contents = readFileSync(file, "utf8"); - for (const { pattern, label } of forbidden) { - if (pattern.test(contents)) violations.push(`${source} uses ${label}`); - pattern.lastIndex = 0; - } - } - - expect(violations, violations.join("\n")).toEqual([]); - }); -}); - -function productionSourceFiles(root = SOURCE_ROOT): string[] { - const files: string[] = []; - for (const entry of readdirSync(root, { withFileTypes: true })) { - const path = join(root, entry.name); - if (entry.isDirectory()) { - if (entry.name !== "test") files.push(...productionSourceFiles(path)); - continue; - } - const extension = entry.name.endsWith(".tsx") - ? ".tsx" - : entry.name.endsWith(".ts") - ? ".ts" - : ""; - if ( - SOURCE_EXTENSIONS.has(extension) && - !entry.name.includes(".test.") && - !entry.name.endsWith(".d.ts") - ) { - files.push(path); - } - } - return files.sort(); -} - -function aliasImports(source: string): string[] { - const imports: string[] = []; - const patterns = [ - /\bfrom\s+["'](@\/[^"']+)["']/g, - /\bimport\s+["'](@\/[^"']+)["']/g, - /\bimport\s*\(\s*["'](@\/[^"']+)["']\s*\)/g, - ]; - for (const pattern of patterns) { - for (const match of source.matchAll(pattern)) { - if (match[1]) imports.push(match[1]); - } - } - return imports; -} - -function sourcePath(file: string): string { - return relative(SOURCE_ROOT, file).split(sep).join("/"); -} - -function inArea(path: string, area: string): boolean { - return path === area || path.startsWith(`${area}/`); -} diff --git a/apps/dashboard/src/components/AppMenu.test.tsx b/apps/dashboard/src/components/AppMenu.test.tsx deleted file mode 100644 index 3884eef7..00000000 --- a/apps/dashboard/src/components/AppMenu.test.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { render, screen, waitFor, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { HttpResponse, http } from "msw"; -import { setupServer } from "msw/node"; -import { MemoryRouter, useLocation } from "react-router-dom"; -import { SWRConfig } from "swr"; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; -import { AppMenu } from "@/components/AppMenu"; -import { TopBar } from "@/components/TopBar"; -import { ThemeProvider } from "@/providers/ThemeProvider"; -import i18n from "@/lib/i18n"; -import { useLocaleStore } from "@/lib/stores/locale"; -import { useThemeStore } from "@/lib/stores/theme"; - -const workspaces = [ - { entryId: "alpha", name: "Alpha", root: "/workspaces/alpha", status: "ready", projectCount: 1 }, - { - entryId: "beta", - name: "Beta", - root: "/workspaces/beta", - status: "identity-conflict", - projectCount: 2, - }, -]; -const server = setupServer( - http.get("http://localhost/api/workspaces", () => HttpResponse.json({ workspaces })), - http.get("http://localhost/api/session", () => - HttpResponse.json({ session: { loggedIn: true, email: "test@example.com" } }), - ), -); -function LocationProbe() { - const location = useLocation(); - return ( - - {location.pathname} - {location.search} - - ); -} -function show(withHeader = false) { - return render( - new Map(), shouldRetryOnError: false }}> - - {withHeader ? : } - - - , - ); -} -beforeAll(() => server.listen({ onUnhandledRequest: "error" })); -beforeEach(async () => { - await i18n.changeLanguage("en-US"); - useThemeStore.setState({ mode: "light" }); - useLocaleStore.setState({ mode: "auto", resolved: "en-US" }); -}); -afterEach(() => { - server.resetHandlers(); - localStorage.removeItem("app_theme_mode"); - localStorage.removeItem("app_locale_mode"); - document.documentElement.classList.remove("light", "dark"); - document.documentElement.removeAttribute("data-theme"); - document.documentElement.style.colorScheme = ""; -}); -afterAll(() => server.close()); - -describe("brand navigation menu", () => { - it.each(["en-US", "zh-CN"])( - "opens from the brand and switches workspace with the environment preserved in %s", - async (locale) => { - await i18n.changeLanguage(locale); - const user = userEvent.setup(); - show(); - const trigger = screen.getByRole("button", { name: i18n.t("appMenu.label") }); - expect(screen.queryByRole("menu")).toBeNull(); - await user.click(trigger); - const menu = await screen.findByRole("menu", { name: i18n.t("appMenu.label") }); - const selected = await within(menu).findByRole("menuitem", { name: /Alpha/ }); - expect(selected.getAttribute("aria-current")).toBe("page"); - expect(within(menu).getByRole("menuitem", { name: /Beta/ }).getAttribute("href")).toBe( - "/workspace/beta?env=staging", - ); - expect(await within(menu).findByText("test@example.com")).toBeDefined(); - await user.click(within(menu).getByRole("menuitem", { name: /Beta/ })); - await waitFor(() => expect(screen.queryByRole("menu")).toBeNull()); - expect(screen.getByTestId("location").textContent).toBe("/workspace/beta?env=staging"); - }, - ); - - it("opens with the keyboard, dismisses with Escape and returns focus to the brand", async () => { - const user = userEvent.setup(); - show(); - const trigger = screen.getByRole("button", { name: "One CLI navigation menu" }); - trigger.focus(); - await user.keyboard("{ArrowDown}"); - await screen.findByRole("menu"); - await user.keyboard("{Home}"); - expect(document.activeElement).toBe(screen.getByRole("menuitem", { name: "Home" })); - await user.keyboard("{Escape}"); - await waitFor(() => expect(screen.queryByRole("menu")).toBeNull()); - expect(document.activeElement).toBe(trigger); - }); - - it.each(["en-US", "zh-CN"])( - "switches theme and language from the header in %s", - async (locale) => { - await i18n.changeLanguage(locale); - const user = userEvent.setup(); - show(true); - const header = within(screen.getByRole("banner")); - await user.click(header.getByRole("button", { name: i18n.t("sidebar.themeToDark") })); - expect(useThemeStore.getState().mode).toBe("dark"); - expect(localStorage.getItem("app_theme_mode")).toBe("dark"); - expect(document.documentElement.classList.contains("dark")).toBe(true); - await user.click(header.getByRole("button", { name: i18n.t("sidebar.themeToLight") })); - expect(useThemeStore.getState().mode).toBe("light"); - const language = header.getByRole("button", { name: i18n.t("sidebar.language") }); - await user.click(language); - await user.click(await screen.findByRole("menuitemradio", { name: "中文" })); - expect(useLocaleStore.getState().mode).toBe("zh-CN"); - expect(localStorage.getItem("app_locale_mode")).toBe("zh-CN"); - await waitFor(() => expect(screen.queryByRole("menu")).toBeNull()); - expect(document.activeElement).toBe(language); - await user.click(header.getByRole("button", { name: i18n.t("appMenu.label") })); - const menu = await screen.findByRole("menu"); - expect(within(menu).queryByRole("menuitem", { name: i18n.t("sidebar.language") })).toBeNull(); - expect( - within(menu).queryByRole("menuitem", { name: i18n.t("sidebar.themeToDark") }), - ).toBeNull(); - }, - ); - - it("shows a retry action when the workspace registry fails", async () => { - server.use( - http.get("http://localhost/api/workspaces", () => new HttpResponse(null, { status: 500 })), - ); - const user = userEvent.setup(); - show(); - await user.click(screen.getByRole("button", { name: "One CLI navigation menu" })); - expect(await screen.findByRole("alert")).toBeDefined(); - server.use( - http.get("http://localhost/api/workspaces", () => HttpResponse.json({ workspaces })), - ); - await user.click(screen.getByRole("menuitem", { name: "Retry" })); - expect(await screen.findByRole("menuitem", { name: /Alpha/ })).toBeDefined(); - expect(screen.getByRole("menuitem", { name: "Home" }).getAttribute("href")).toBe( - "/?env=staging", - ); - }); -}); diff --git a/apps/dashboard/src/components/TopBar.test.tsx b/apps/dashboard/src/components/TopBar.test.tsx deleted file mode 100644 index 71dc1713..00000000 --- a/apps/dashboard/src/components/TopBar.test.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { render, screen, waitFor, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { MemoryRouter } from "react-router-dom"; -import { SWRConfig } from "swr"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { applyManifestDraft, previewManifestDraft } from "@/api/manifest"; -import { workspacesKey } from "@/api/workspaces"; -import { ManifestSaveControl, TopBar } from "@/components/TopBar"; -import { useManifestDraftStore } from "@/features/manifest-draft/manifest-draft-store"; -import i18n from "@/lib/i18n"; -import type { WorkspacesResponse } from "@/types/api"; - -vi.mock("@/api/manifest", () => ({ - applyManifestDraft: vi.fn(), - previewManifestDraft: vi.fn(), -})); - -const emptyRegistry: WorkspacesResponse = { - schema: "one-cli/workspaces/v1", - workspaces: [], -}; - -function renderTopBar(path: string) { - return render( - new Map(), - fallback: { [workspacesKey]: emptyRegistry }, - revalidateOnMount: false, - }} - > - - - - , - ); -} - -function renderManifestSaveControl(path: string) { - return render( - new Map(), revalidateOnMount: false }}> - - - - , - ); -} - -describe("TopBar and manifest review", () => { - beforeAll(async () => { - await i18n.changeLanguage("en-US"); - }); - beforeEach(() => { - vi.mocked(previewManifestDraft).mockResolvedValue({ - schema: "one-cli/workspace-manifest-preview/v1", - revision: "sha256:base", - before: - "version = 2\n[env.infisical]\nprojectId = 'old-project'\nenvironments = ['dev','staging','prod']\n", - after: - "version = 2\n[env.infisical]\nprojectId = 'new-project'\nenvironments = ['dev','staging','prod']\n", - }); - }); - afterEach(() => { - useManifestDraftStore.getState().clearWorkspace("demo-entry"); - vi.clearAllMocks(); - }); - - it.each([ - "/", - "/settings?env=staging", - "/settings/env/infisical?env=staging", - "/profile?env=staging", - "/section/env/infisical?env=staging", - ])("keeps the global TopBar free of workspace environment controls at %s", (path) => { - renderTopBar(path); - expect(screen.queryByRole("combobox", { name: /^Environment:/ })).toBeNull(); - }); - - it("reviews and publishes a Workspace Infisical binding draft", async () => { - useManifestDraftStore.getState().stageWorkspaceSection({ - entryId: "demo-entry", - revision: "sha256:base", - section: "environment", - initial: { backend: "infisical", projectId: "old-project" }, - next: { backend: "infisical", projectId: "new-project" }, - labels: { projectId: "global.project" }, - }); - const user = userEvent.setup(); - renderManifestSaveControl("/workspace/demo-entry?env=dev"); - - await user.click(screen.getByRole("button", { name: "Save changes · 1" })); - const dialog = await screen.findByRole("alertdialog"); - expect(await within(dialog).findByText(/projectId = 'old-project'/)).toBeDefined(); - expect(within(dialog).getByText(/projectId = 'new-project'/)).toBeDefined(); - expect(previewManifestDraft).toHaveBeenCalledWith( - { - revision: "sha256:base", - workspace: { environment: { backend: "infisical", projectId: "new-project" } }, - changes: [], - }, - "demo-entry", - ); - - await user.click(within(dialog).getByRole("button", { name: "Save to manifest" })); - await waitFor(() => - expect(applyManifestDraft).toHaveBeenCalledWith( - { - revision: "sha256:base", - workspace: { environment: { backend: "infisical", projectId: "new-project" } }, - changes: [], - }, - "demo-entry", - ), - ); - }); - - it("retains the entire draft when atomic publication fails", async () => { - vi.mocked(applyManifestDraft).mockRejectedValue({ - status: 500, - code: "ONE_CLI_ERROR", - message: "Project publication failed.", - context: {}, - remediation: [], - }); - useManifestDraftStore.getState().stageWorkspaceSection({ - entryId: "demo-entry", - revision: "sha256:base", - section: "environment", - initial: { backend: "infisical", projectId: "old-project" }, - next: { backend: "infisical", projectId: "new-project" }, - labels: { projectId: "global.project" }, - }); - const user = userEvent.setup(); - renderManifestSaveControl("/workspace/demo-entry?env=dev"); - - await user.click(screen.getByRole("button", { name: "Save changes · 1" })); - const dialog = await screen.findByRole("alertdialog"); - const saveButton = within(dialog).getByRole("button", { name: "Save to manifest" }); - await waitFor(() => expect((saveButton as HTMLButtonElement).disabled).toBe(false)); - await user.click(saveButton); - expect(await screen.findByText("Project publication failed.")).toBeDefined(); - expect(applyManifestDraft).toHaveBeenCalledWith( - { - revision: "sha256:base", - workspace: { environment: { backend: "infisical", projectId: "new-project" } }, - changes: [], - }, - "demo-entry", - ); - const remaining = useManifestDraftStore.getState().drafts["demo-entry"]; - expect(remaining.revision).toBe("sha256:base"); - expect(remaining.workspace).toEqual({ - environment: { backend: "infisical", projectId: "new-project" }, - }); - expect(Object.keys(remaining.changes)).toEqual([]); - }); -}); diff --git a/apps/dashboard/src/components/ui/modal-focus.test.tsx b/apps/dashboard/src/components/ui/modal-focus.test.tsx deleted file mode 100644 index 0c029ee2..00000000 --- a/apps/dashboard/src/components/ui/modal-focus.test.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { useState } from "react"; -import { describe, expect, it } from "vitest"; -import { - AlertDialog, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { Button } from "@/components/ui/button"; -import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"; -import { Sheet, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet"; - -describe("controlled modal focus", () => { - it("returns focus after a controlled Sheet without a rendered trigger closes", async () => { - const user = userEvent.setup(); - render(); - const opener = screen.getByRole("button", { name: "Open inspector" }); - - await user.click(opener); - const dialog = await screen.findByRole("dialog"); - await user.click(screen.getByRole("button", { name: "Close" })); - await waitFor(() => expect(dialog.isConnected).toBe(false)); - - expect(document.activeElement).toBe(opener); - }); - - it("returns focus after a controlled AlertDialog without a rendered trigger closes", async () => { - const user = userEvent.setup(); - render(); - const opener = screen.getByRole("button", { name: "Forget workspace" }); - - await user.click(opener); - const dialog = await screen.findByRole("alertdialog"); - await user.click(screen.getByRole("button", { name: "Cancel" })); - await waitFor(() => expect(dialog.isConnected).toBe(false)); - - expect(document.activeElement).toBe(opener); - }); - - it("returns focus after a controlled Dialog without a rendered trigger closes", async () => { - const user = userEvent.setup(); - render(); - const opener = screen.getByRole("button", { name: "Add profile" }); - - await user.click(opener); - const dialog = await screen.findByRole("dialog"); - await user.click(screen.getByRole("button", { name: "Close" })); - await waitFor(() => expect(dialog.isConnected).toBe(false)); - - expect(document.activeElement).toBe(opener); - }); -}); - -function ControlledSheet() { - const [open, setOpen] = useState(false); - return ( - <> - - - - Project inspector - Configure this project. - - - - ); -} - -function ControlledAlertDialog() { - const [open, setOpen] = useState(false); - return ( - <> - - - - Forget this workspace? - This only removes the registry entry. - Cancel - - - - ); -} - -function ControlledDialog() { - const [open, setOpen] = useState(false); - return ( - <> - - - - Add profile - Add machine-local credentials. - - - - ); -} diff --git a/apps/dashboard/src/features/environment-context/EnvironmentSelector.test.tsx b/apps/dashboard/src/features/environment-context/EnvironmentSelector.test.tsx deleted file mode 100644 index 89b7650c..00000000 --- a/apps/dashboard/src/features/environment-context/EnvironmentSelector.test.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { MemoryRouter, useLocation } from "react-router-dom"; -import { afterEach, beforeAll, describe, expect, it } from "vitest"; -import { EnvironmentLink } from "@/features/environment-context/EnvironmentLink"; -import { EnvironmentSelector } from "@/features/environment-context/EnvironmentSelector"; -import { useEnvironmentDirtyStore } from "@/features/environment-context/environment-dirty-store"; -import i18n from "@/lib/i18n"; - -function LocationProbe() { - const location = useLocation(); - return {location.search}; -} - -function RouteProbe() { - const location = useLocation(); - return {`${location.pathname}${location.search}`}; -} - -function renderSelector(path: string) { - return render( - - - - , - ); -} - -describe("EnvironmentSelector", () => { - beforeAll(async () => { - await i18n.changeLanguage("en-US"); - }); - afterEach(() => { - useEnvironmentDirtyStore.getState().reset(); - }); - - it("defaults to dev without writing the URL on mount", () => { - renderSelector("/workspace/demo?panel=summary"); - - const selector = screen.getByRole("combobox", { name: "Environment: Development" }); - expect(selector.textContent).toContain("Development"); - expect(screen.getByTestId("search").textContent).toBe("?panel=summary"); - }); - - it("uses the env query and preserves every other query parameter", async () => { - const user = userEvent.setup(); - renderSelector("/workspace/demo?view=compact&panel=deploy&env=staging"); - - const selector = screen.getByRole("combobox", { name: "Environment: Staging" }); - expect(selector.textContent).toContain("Staging"); - await user.click(selector); - await user.click(await screen.findByRole("option", { name: "Production" })); - - const search = new URLSearchParams( - screen.getByTestId("search").textContent?.replace(/^\?/, ""), - ); - expect(search.get("env")).toBe("prod"); - expect(search.get("view")).toBe("compact"); - expect(search.get("panel")).toBe("deploy"); - }); - - it("keeps the selected environment while navigating between Dashboard pages", async () => { - const user = userEvent.setup(); - render( - - Open Infisical - - , - ); - - await user.click(screen.getByRole("link", { name: "Open Infisical" })); - - expect(screen.getByTestId("route").textContent).toBe("/settings/env/infisical?env=staging"); - }); - - it("keeps guarding while any independent editor remains dirty", async () => { - const state = useEnvironmentDirtyStore.getState(); - state.setDirty("workspace-editor", true); - state.setDirty("project-editor", true); - state.clearOwner("project-editor"); - expect(useEnvironmentDirtyStore.getState().dirty).toBe(true); - - const user = userEvent.setup(); - renderSelector("/workspace/demo?env=dev"); - await user.click(screen.getByRole("combobox", { name: "Environment: Development" })); - await user.click(await screen.findByRole("option", { name: "Production" })); - - expect(await screen.findByRole("alertdialog")).toBeDefined(); - expect(screen.getByTestId("search").textContent).toBe("?env=dev"); - }); -}); diff --git a/apps/dashboard/src/features/global-variables/GlobalVariables.test.tsx b/apps/dashboard/src/features/global-variables/GlobalVariables.test.tsx deleted file mode 100644 index a8ece2f0..00000000 --- a/apps/dashboard/src/features/global-variables/GlobalVariables.test.tsx +++ /dev/null @@ -1,290 +0,0 @@ -import { act, render, screen, waitFor, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { MemoryRouter } from "react-router-dom"; -import { SWRConfig } from "swr"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import * as api from "@/api/session"; -import i18n from "@/lib/i18n"; -import { GlobalVariables } from "./GlobalVariables"; - -vi.mock("@/api/session", async (original) => ({ - ...(await original()), - getSession: vi.fn(), - getLocation: vi.fn(), - getProject: vi.fn(), - getProjects: vi.fn(), - createRemoteProject: vi.fn(), - initializeGlobalLocation: vi.fn(), - bindLocation: vi.fn(), - getGlobalListing: vi.fn(), - readGlobalSecret: vi.fn(), - saveGlobalSecret: vi.fn(), - deleteGlobalSecret: vi.fn(), - createGlobalFolder: vi.fn(), -})); -const location: api.GlobalLocation = { - siteUrl: "https://app.infisical.com", - userId: "user", - organizationId: "org", - projectId: "shared", - projectName: "Shared", - defaultEnvironment: "dev", -}; -beforeEach(async () => { - vi.resetAllMocks(); - await i18n.changeLanguage("en-US"); - vi.mocked(api.getSession).mockResolvedValue({ - session: { - loggedIn: true, - expired: false, - userId: "user", - siteUrl: location.siteUrl, - organizationId: "org", - }, - }); - vi.mocked(api.getLocation).mockResolvedValue({ location }); - vi.mocked(api.getProjects).mockResolvedValue([]); - vi.mocked(api.getProject).mockResolvedValue({ - id: "shared", - name: "Shared", - orgId: "org", - environments: [ - { name: "Development", slug: "dev" }, - { name: "Production", slug: "prod" }, - ], - }); - vi.mocked(api.getGlobalListing).mockImplementation(async (query) => ({ - location, - environment: new URLSearchParams(query).get("env")!, - path: "/", - folders: [], - variables: [{ key: "OSS_AK", description: "Upload assets" }], - })); - vi.mocked(api.readGlobalSecret).mockResolvedValue({ value: "sensitive-test-value" }); -}); -function mount() { - return render( - new Map(), dedupingInterval: 0, shouldRetryOnError: false }} - > - - - - , - ); -} -describe("global credential browsing", () => { - it("lists metadata without fetching values and clears a revealed value on environment change", async () => { - mount(); - const user = userEvent.setup(); - await screen.findByText("OSS_AK"); - expect(api.readGlobalSecret).not.toHaveBeenCalled(); - await user.click(screen.getByRole("button", { name: "Reveal" })); - await screen.findByText("sensitive-test-value"); - await user.click(screen.getByRole("combobox", { name: "Browsing environment" })); - await user.click(await screen.findByRole("option", { name: "Production (prod)" })); - await waitFor(() => expect(api.getGlobalListing).toHaveBeenCalledWith("?env=prod&path=%2F")); - expect(screen.queryByText("sensitive-test-value")).toBeNull(); - }); - it("discards a late plaintext response after leaving the page", async () => { - let resolve!: (v: { value: string }) => void; - vi.mocked(api.readGlobalSecret).mockReturnValue( - new Promise((r) => { - resolve = r; - }), - ); - const page = mount(); - const user = userEvent.setup(); - await screen.findByText("OSS_AK"); - await user.click(screen.getByRole("button", { name: "Reveal" })); - page.unmount(); - await act(async () => resolve({ value: "late-secret" })); - expect(screen.queryByText("late-secret")).toBeNull(); - }); - it("shows fetch failures instead of an empty-folder result", async () => { - vi.mocked(api.getGlobalListing).mockRejectedValue(new Error("Permission denied")); - mount(); - await screen.findByRole("alert"); - expect(screen.getByRole("alert").textContent).toContain("Permission denied"); - expect(screen.queryByText("No variables in this folder.")).toBeNull(); - }); -}); - -async function openBinding(user: ReturnType, existing = false) { - await user.click( - await screen.findByRole("button", { - name: i18n.t(existing ? "binding.change" : "binding.globalTitle"), - }), - ); - return within(await screen.findByRole("dialog", { name: i18n.t("binding.globalTitle") })); -} -async function chooseExisting(user: ReturnType) { - await user.click(screen.getByRole("combobox", { name: i18n.t("binding.method") })); - await user.click(await screen.findByRole("option", { name: i18n.t("binding.existing") })); -} -describe("shared credential setup", () => { - it.each(["en-US", "zh-CN"])( - "binds the default storage only after confirmation in %s", - async (locale) => { - await i18n.changeLanguage(locale); - vi.mocked(api.getLocation).mockResolvedValue({ location: null }); - vi.mocked(api.initializeGlobalLocation).mockImplementation(async () => { - vi.mocked(api.getLocation).mockResolvedValue({ location }); - return { location }; - }); - mount(); - const user = userEvent.setup(); - const dialog = await openBinding(user); - expect(api.initializeGlobalLocation).not.toHaveBeenCalled(); - expect(api.getGlobalListing).not.toHaveBeenCalled(); - await user.click(dialog.getByRole("button", { name: i18n.t("binding.prepareAndBind") })); - await screen.findByText("OSS_AK"); - expect(api.initializeGlobalLocation).toHaveBeenCalledTimes(1); - await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); - }, - ); - it("creates and selects a custom project, then binds only on confirmation", async () => { - vi.mocked(api.getLocation).mockResolvedValue({ location: null }); - const created = { - id: "team", - name: "Team", - orgId: "org", - environments: [{ name: "Development", slug: "dev" }], - }; - vi.mocked(api.createRemoteProject).mockResolvedValue(created); - vi.mocked(api.getProject).mockResolvedValue(created); - vi.mocked(api.bindLocation).mockImplementation(async () => { - vi.mocked(api.getLocation).mockResolvedValue({ location }); - return { location }; - }); - mount(); - const user = userEvent.setup(); - await openBinding(user); - await chooseExisting(user); - await user.click(screen.getByRole("button", { name: "New project" })); - await user.type(screen.getByLabelText("Project name"), "Team"); - await user.click(screen.getByRole("button", { name: "Create and select" })); - await waitFor(() => expect(screen.queryByRole("dialog", { name: "New project" })).toBeNull()); - expect(api.createRemoteProject).toHaveBeenCalledWith("Team"); - expect(api.bindLocation).not.toHaveBeenCalled(); - await user.click(screen.getByRole("button", { name: i18n.t("binding.bindExisting") })); - await screen.findByText("OSS_AK"); - expect(api.bindLocation).toHaveBeenCalledWith("team", "dev"); - }); - it("keeps a failed creation editable and leaves the existing location alone", async () => { - vi.mocked(api.createRemoteProject).mockRejectedValue( - new Error("No permission to create projects"), - ); - mount(); - const user = userEvent.setup(); - await openBinding(user, true); - await user.click(screen.getByRole("button", { name: "New project" })); - await user.type(screen.getByLabelText("Project name"), "Team"); - await user.click(screen.getByRole("button", { name: "Create and select" })); - await screen.findByText("No permission to create projects"); - expect((screen.getByLabelText("Project name") as HTMLInputElement).value).toBe("Team"); - expect(api.bindLocation).not.toHaveBeenCalled(); - expect(api.initializeGlobalLocation).not.toHaveBeenCalled(); - }); - it("retains default setup failures and retries without an empty credential list", async () => { - vi.mocked(api.getLocation).mockResolvedValue({ location: null }); - vi.mocked(api.initializeGlobalLocation) - .mockRejectedValueOnce(new Error("Default environment is missing")) - .mockImplementationOnce(async () => { - vi.mocked(api.getLocation).mockResolvedValue({ location }); - return { location }; - }); - mount(); - const user = userEvent.setup(); - const dialog = await openBinding(user); - await user.click(dialog.getByRole("button", { name: i18n.t("binding.prepareAndBind") })); - await dialog.findByText("Default environment is missing"); - expect(api.getGlobalListing).not.toHaveBeenCalled(); - await user.click(dialog.getByRole("button", { name: i18n.t("binding.prepareAndBind") })); - await screen.findByText("OSS_AK"); - }); -}); - -describe("credential editor recovery", () => { - it("shows a recoverable search empty state without fetching secret values", async () => { - mount(); - const user = userEvent.setup(); - await screen.findByText("OSS_AK"); - await user.type(screen.getByRole("textbox", { name: "Search variable names" }), "unmatched"); - expect(screen.getByRole("heading", { name: "No matching variables" })).toBeDefined(); - await user.click(screen.getByRole("button", { name: "Clear search" })); - expect(screen.getByText("OSS_AK")).toBeDefined(); - expect(api.readGlobalSecret).not.toHaveBeenCalled(); - }); - it("submits with Enter, retains failed input, and retries the same value", async () => { - vi.mocked(api.saveGlobalSecret) - .mockRejectedValueOnce(new Error("Write denied")) - .mockResolvedValueOnce(undefined); - mount(); - const user = userEvent.setup(); - await screen.findByText("OSS_AK"); - await user.click(screen.getByRole("button", { name: "Add variable" })); - await user.type(screen.getByLabelText("Name"), " NEW_KEY "); - await user.type(screen.getByLabelText("New value"), "test-only{Enter}"); - const dialog = screen.getByRole("dialog"); - expect(await within(dialog).findByText("Write denied")).toBeDefined(); - expect((screen.getByLabelText("New value") as HTMLInputElement).value).toBe("test-only"); - await user.click(within(dialog).getByRole("button", { name: "Save to Infisical" })); - await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); - expect(api.saveGlobalSecret).toHaveBeenNthCalledWith( - 2, - "NEW_KEY", - "test-only", - "?env=dev&path=%2F", - false, - ); - }); - it("keeps edits when Escape is cancelled, then discards without a remote write", async () => { - mount(); - const user = userEvent.setup(); - await screen.findByText("OSS_AK"); - await user.click(screen.getByRole("button", { name: "Add variable" })); - await user.type(screen.getByLabelText("Name"), "DRAFT_KEY"); - await user.keyboard("{Escape}"); - expect(screen.getByRole("alertdialog", { name: "Discard unsaved changes?" })).toBeDefined(); - await user.click(screen.getByRole("button", { name: "Keep editing" })); - expect((screen.getByLabelText("Name") as HTMLInputElement).value).toBe("DRAFT_KEY"); - await user.click(screen.getByRole("button", { name: "Cancel" })); - await user.click(screen.getByRole("button", { name: "Discard changes" })); - await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); - expect(api.saveGlobalSecret).not.toHaveBeenCalled(); - }); - it("opens edit and delete from the row menu and keeps delete cancel harmless", async () => { - mount(); - const user = userEvent.setup(); - await screen.findByText("OSS_AK"); - await user.click(screen.getByRole("button", { name: "More actions for OSS_AK" })); - await user.click(screen.getByRole("menuitem", { name: "Edit" })); - expect((screen.getByLabelText("Name") as HTMLInputElement).readOnly).toBe(true); - await user.click(screen.getByRole("button", { name: "Cancel" })); - await waitFor(() => - expect(document.activeElement).toBe( - screen.getByRole("button", { name: "More actions for OSS_AK" }), - ), - ); - await user.click(screen.getByRole("button", { name: "More actions for OSS_AK" })); - await user.click(screen.getByRole("menuitem", { name: "Delete" })); - const dialog = screen.getByRole("alertdialog"); - expect(dialog.textContent).toContain("OSS_AK"); - await user.click(within(dialog).getByRole("button", { name: "Cancel" })); - expect(api.deleteGlobalSecret).not.toHaveBeenCalled(); - }); - it("rejects whitespace-only folder names and allows cancelling an empty folder", async () => { - mount(); - const user = userEvent.setup(); - await screen.findByText("OSS_AK"); - await user.click(screen.getByRole("button", { name: "New folder" })); - await user.type(screen.getByLabelText("Folder name"), " "); - expect( - (screen.getByRole("button", { name: "Save to Infisical" }) as HTMLButtonElement).disabled, - ).toBe(true); - await user.clear(screen.getByLabelText("Folder name")); - await user.click(screen.getByRole("button", { name: "Cancel" })); - expect(api.createGlobalFolder).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/dashboard/src/features/infisical-session/AccountSettings.test.tsx b/apps/dashboard/src/features/infisical-session/AccountSettings.test.tsx deleted file mode 100644 index 4cc33aa8..00000000 --- a/apps/dashboard/src/features/infisical-session/AccountSettings.test.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { act, render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { MemoryRouter } from "react-router-dom"; -import { SWRConfig } from "swr"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import * as api from "@/api/session"; -import i18n from "@/lib/i18n"; -import { AccountSettings } from "./AccountSettings"; -import { useThemeStore } from "@/lib/stores/theme"; -import { ThemeProvider } from "@/providers/ThemeProvider"; -vi.mock("@/api/session", async (original) => ({ - ...(await original()), - getSession: vi.fn(), - startLogin: vi.fn(), - cancelLogin: vi.fn(), - logout: vi.fn(), -})); -beforeEach(async () => { - vi.resetAllMocks(); - useThemeStore.setState({ mode: "light" }); - await i18n.changeLanguage("en-US"); -}); -afterEach(() => { - localStorage.removeItem("app_theme_mode"); - document.documentElement.classList.remove("dark", "light"); - document.documentElement.removeAttribute("data-theme"); - document.documentElement.style.colorScheme = ""; -}); -function mount() { - return render( - new Map(), dedupingInterval: 0, shouldRetryOnError: false }} - > - - - - - - , - ); -} -describe("account state and recovery", () => { - it("shows only a loading state before the session resolves", async () => { - let finish!: (value: api.SessionState) => void; - vi.mocked(api.getSession).mockReturnValue( - new Promise((resolve) => { - finish = resolve; - }), - ); - mount(); - expect(screen.getByRole("status")).toBeDefined(); - expect(screen.queryByRole("button", { name: "Sign in with browser" })).toBeNull(); - await act(async () => - finish({ session: { loggedIn: true, expired: false, email: "demo@example.com" } }), - ); - expect(await screen.findByText("Connected")).toBeDefined(); - expect(screen.getByText("demo@example.com")).toBeDefined(); - }); - it("offers retry on session failure and recovers to the signed-out state", async () => { - vi.mocked(api.getSession) - .mockRejectedValueOnce(new Error("Session unavailable")) - .mockResolvedValue({ session: { loggedIn: false, expired: false } }); - mount(); - const user = userEvent.setup(); - expect(await screen.findByText("Session unavailable")).toBeDefined(); - await user.click(screen.getByRole("button", { name: "Retry" })); - expect(await screen.findByRole("button", { name: "Sign in with browser" })).toBeDefined(); - }); - it("keeps the waiting login visible with reopen and cancel actions", async () => { - vi.mocked(api.getSession).mockResolvedValue({ - session: { loggedIn: false, expired: false }, - login: { status: "waiting", url: "https://app.infisical.com/test-login" }, - }); - mount(); - expect(await screen.findByRole("link", { name: "Reopen login page" })).toBeDefined(); - expect(screen.getByRole("button", { name: "Cancel" })).toBeDefined(); - expect(api.startLogin).not.toHaveBeenCalled(); - }); -}); - -describe("theme preference", () => { - it.each(["en-US", "zh-CN"])( - "saves the theme and follows shared theme changes in %s", - async (locale) => { - await i18n.changeLanguage(locale); - vi.mocked(api.getSession).mockResolvedValue({ session: { loggedIn: false, expired: false } }); - const user = userEvent.setup(); - mount(); - await user.click(screen.getByRole("combobox", { name: i18n.t("session.theme") })); - await user.click(await screen.findByRole("option", { name: i18n.t("session.themeDark") })); - expect(useThemeStore.getState().mode).toBe("dark"); - expect(localStorage.getItem("app_theme_mode")).toBe("dark"); - expect(document.documentElement.classList.contains("dark")).toBe(true); - await act(async () => { - useThemeStore.getState().toggle(); - }); - expect(screen.getByRole("combobox", { name: i18n.t("session.theme") }).textContent).toBe( - i18n.t("session.themeLight"), - ); - expect(localStorage.getItem("app_theme_mode")).toBe("light"); - expect(document.documentElement.classList.contains("dark")).toBe(false); - }, - ); -}); diff --git a/apps/dashboard/src/features/project-creation/CreateProjectDialog.test.tsx b/apps/dashboard/src/features/project-creation/CreateProjectDialog.test.tsx deleted file mode 100644 index 2a84276e..00000000 --- a/apps/dashboard/src/features/project-creation/CreateProjectDialog.test.tsx +++ /dev/null @@ -1,312 +0,0 @@ -import { render, screen, waitFor, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { HttpResponse, http } from "msw"; -import { setupServer } from "msw/node"; -import { SWRConfig } from "swr"; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { CreateProjectDialog } from "@/features/project-creation/CreateProjectDialog"; -import i18n from "@/lib/i18n"; - -const emptyTemplates = [ - { id: "empty-app", category: "frontend", directory: "apps" }, - { id: "empty-service", category: "backend", directory: "services" }, - { id: "empty-library", category: "library", directory: "packages" }, -].map((template) => ({ - ...template, - name: template.id, - description: template.id, - toolchain: "none", -})); - -const templates = [ - ...emptyTemplates, - { - id: "react-spa", - name: "React", - description: "React", - category: "frontend", - directory: "apps", - toolchain: "node", - }, - { - id: "electron-app", - name: "Electron", - description: "Electron", - category: "frontend", - directory: "apps", - toolchain: "node", - projects: [ - { directory: "apps", suffix: "-renderer" }, - { directory: "services", suffix: "-main" }, - { directory: "packages", suffix: "-preload" }, - ], - }, - { - id: "go-api", - name: "Go", - description: "Go", - category: "backend", - directory: "services", - toolchain: "go", - }, -]; -const server = setupServer(); -function show(projectNames: string[] = []) { - const onClose = vi.fn(); - const onCreated = vi.fn(); - render( - new Map(), shouldRetryOnError: false }}> - - , - ); - return { onClose, onCreated }; -} - -beforeAll(() => server.listen({ onUnhandledRequest: "error" })); -beforeEach(async () => { - await i18n.changeLanguage("en-US"); - server.use( - http.get("http://localhost/api/project-templates", () => - HttpResponse.json({ templates: [...templates].reverse() }), - ), - ); -}); -afterEach(() => server.resetHandlers()); -afterAll(() => server.close()); - -describe("project creation", () => { - it.each(["en-US", "zh-CN"])("can skip development skills in %s", async (locale) => { - await i18n.changeLanguage(locale); - const user = userEvent.setup(); - let payload: unknown; - server.use( - http.post("http://localhost/api/workspaces/:entryId/projects", async ({ request }) => { - payload = await request.json(); - return HttpResponse.json({ name: "web", relativeDir: "apps/web", templateId: "react-spa" }); - }), - ); - const { onCreated } = show(); - await user.type(screen.getByLabelText(i18n.t("projectCreate.name")), "web"); - const toggle = screen.getByRole("switch", { name: i18n.t("projectCreate.installSkills") }); - expect(toggle.getAttribute("aria-checked")).toBe("true"); - await user.click(toggle); - await waitFor(() => - expect( - screen - .getByRole("button", { name: i18n.t("projectCreate.submit") }) - .hasAttribute("disabled"), - ).toBe(false), - ); - await user.click(screen.getByRole("button", { name: i18n.t("projectCreate.submit") })); - await waitFor(() => expect(onCreated).toHaveBeenCalledWith("web")); - expect(payload).toEqual({ name: "web", templateId: "react-spa", skipSkills: true }); - }); - it.each( - ["en-US", "zh-CN"].flatMap((locale) => - [...emptyTemplates, { id: "go-api", directory: "services" }].map((template) => ({ - locale, - ...template, - })), - ), - )("creates $id in $locale", async ({ locale, id, directory }) => { - await i18n.changeLanguage(locale); - const user = userEvent.setup(); - let payload: unknown; - server.use( - http.post( - "http://localhost/api/workspaces/:entryId/projects", - async ({ params, request }) => { - expect(params.entryId).toBe("selected-workspace"); - payload = await request.json(); - return HttpResponse.json( - { name: "api", relativeDir: `${directory}/api`, templateId: id }, - { status: 201 }, - ); - }, - ), - ); - const { onClose, onCreated } = show(); - await user.type(screen.getByLabelText(i18n.t("projectCreate.name")), "api"); - const select = await screen.findByRole("combobox", { name: i18n.t("projectCreate.template") }); - await waitFor(() => expect((select as HTMLButtonElement).disabled).toBe(false)); - await user.click(select); - await user.click( - await screen.findByRole("option", { name: i18n.t(`projectCreate.templates.${id}.name`) }), - ); - expect(screen.getByText(`${directory}/api`)).toBeDefined(); - await user.click(screen.getByRole("button", { name: i18n.t("projectCreate.submit") })); - await waitFor(() => expect(onCreated).toHaveBeenCalledWith("api")); - expect(payload).toEqual({ name: "api", templateId: id }); - expect(onClose).toHaveBeenCalledOnce(); - }); - - it.each(["en-US", "zh-CN"])("previews and creates all Electron members in %s", async (locale) => { - await i18n.changeLanguage(locale); - const user = userEvent.setup(); - server.use( - http.post("http://localhost/api/workspaces/:entryId/projects", async ({ request }) => { - expect(await request.json()).toEqual({ name: "desktop", templateId: "electron-app" }); - return HttpResponse.json( - { - name: "desktop", - relativeDir: ".", - templateId: "electron-app", - projects: [ - { - name: "desktop-renderer", - relativeDir: "apps/desktop-renderer", - templateId: "electron-app", - }, - { - name: "desktop-main", - relativeDir: "services/desktop-main", - templateId: "electron-app", - }, - { - name: "desktop-preload", - relativeDir: "packages/desktop-preload", - templateId: "electron-app", - }, - ], - }, - { status: 201 }, - ); - }), - ); - const { onCreated } = show(); - await user.type(screen.getByLabelText(i18n.t("projectCreate.name")), "desktop"); - const select = await screen.findByRole("combobox", { name: i18n.t("projectCreate.template") }); - await waitFor(() => expect((select as HTMLButtonElement).disabled).toBe(false)); - await user.click(select); - await user.click( - await screen.findByRole("option", { - name: i18n.t("projectCreate.templates.electron-app.name"), - }), - ); - for (const dir of [ - "apps/desktop-renderer", - "services/desktop-main", - "packages/desktop-preload", - ]) - expect(screen.getByText(dir)).toBeDefined(); - await user.click(screen.getByRole("button", { name: i18n.t("projectCreate.submit") })); - await waitFor(() => expect(onCreated).toHaveBeenCalledWith("desktop-renderer")); - }); - - it("rejects a conflicting Electron member before creation", async () => { - const user = userEvent.setup(); - const post = vi.fn(); - server.use(http.post("http://localhost/api/workspaces/:entryId/projects", post)); - show(["desktop-main"]); - await user.type(screen.getByLabelText("Project name"), "desktop"); - const select = await screen.findByRole("combobox", { name: i18n.t("projectCreate.template") }); - await waitFor(() => expect((select as HTMLButtonElement).disabled).toBe(false)); - await user.click(select); - await user.click( - await screen.findByRole("option", { - name: i18n.t("projectCreate.templates.electron-app.name"), - }), - ); - await user.click(screen.getByRole("button", { name: "Create project" })); - expect(screen.getByRole("alert")).toBeDefined(); - expect(post).not.toHaveBeenCalled(); - }); - - it("validates names and duplicates without sending requests", async () => { - const user = userEvent.setup(); - const post = vi.fn(); - server.use(http.post("http://localhost/api/workspaces/:entryId/projects", post)); - show(["web"]); - const name = screen.getByLabelText("Project name"); - const submit = screen.getByRole("button", { name: "Create project" }); - await waitFor(() => expect((submit as HTMLButtonElement).disabled).toBe(false)); - for (const value of ["../outside", "web"]) { - await user.clear(name); - await user.type(name, value); - await user.click(submit); - expect(name.getAttribute("aria-invalid")).toBe("true"); - expect(document.activeElement).toBe(name); - expect(screen.getByRole("alert")).toBeDefined(); - } - expect(post).not.toHaveBeenCalled(); - }); - - it("retains input on server errors and allows retry", async () => { - const user = userEvent.setup(); - let calls = 0; - server.use( - http.post("http://localhost/api/workspaces/:entryId/projects", () => { - calls++; - if (calls === 1) - return HttpResponse.json( - { error: { code: "TARGET_EXISTS", message: "Directory already exists" } }, - { status: 409 }, - ); - return HttpResponse.json( - { name: "web", relativeDir: "apps/web", templateId: "react-spa" }, - { status: 201 }, - ); - }), - ); - const { onCreated } = show(); - await user.type(screen.getByLabelText("Project name"), "web"); - await user.click(screen.getByRole("button", { name: "Create project" })); - expect(await screen.findByText("Directory already exists")).toBeDefined(); - expect((screen.getByLabelText("Project name") as HTMLInputElement).value).toBe("web"); - await user.click(screen.getByRole("button", { name: "Create project" })); - await waitFor(() => expect(onCreated).toHaveBeenCalledWith("web")); - }); - - it("blocks duplicate submissions and closing while creation is pending", async () => { - const user = userEvent.setup(); - let release!: () => void; - const blocked = new Promise((resolve) => { - release = resolve; - }); - const post = vi.fn(async () => { - await blocked; - return HttpResponse.json( - { name: "web", relativeDir: "apps/web", templateId: "react-spa" }, - { status: 201 }, - ); - }); - server.use(http.post("http://localhost/api/workspaces/:entryId/projects", post)); - const { onClose } = show(); - await user.type(screen.getByLabelText("Project name"), "web"); - await user.dblClick(screen.getByRole("button", { name: "Create project" })); - expect(await screen.findByRole("status")).toBeDefined(); - expect((screen.getByRole("button", { name: "Cancel" }) as HTMLButtonElement).disabled).toBe( - true, - ); - await user.keyboard("{Escape}"); - expect(onClose).not.toHaveBeenCalled(); - expect(post).toHaveBeenCalledOnce(); - release(); - await waitFor(() => expect(onClose).toHaveBeenCalledOnce()); - }); - - it("recovers from template loading failure and updates translated template labels", async () => { - const user = userEvent.setup(); - server.use( - http.get( - "http://localhost/api/project-templates", - () => new HttpResponse(null, { status: 500 }), - ), - ); - show(); - expect(await screen.findByText("Could not load templates")).toBeDefined(); - server.use( - http.get("http://localhost/api/project-templates", () => HttpResponse.json({ templates })), - ); - await user.click(screen.getByRole("button", { name: "Retry" })); - await screen.findByRole("combobox", { name: i18n.t("projectCreate.template") }); - await i18n.changeLanguage("zh-CN"); - const dialog = await screen.findByRole("dialog", { name: "新建项目" }); - expect(await within(dialog).findByText("React 单页应用")).toBeDefined(); - }); -}); diff --git a/apps/dashboard/src/features/secrets/SecretsManager.test.tsx b/apps/dashboard/src/features/secrets/SecretsManager.test.tsx deleted file mode 100644 index faebaa7e..00000000 --- a/apps/dashboard/src/features/secrets/SecretsManager.test.tsx +++ /dev/null @@ -1,463 +0,0 @@ -import { render, screen, waitFor, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { HttpResponse, http } from "msw"; -import { setupServer } from "msw/node"; -import { SWRConfig } from "swr"; -import { MemoryRouter } from "react-router-dom"; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; -import { SecretsManager } from "@/features/secrets/SecretsManager"; -import i18n from "@/lib/i18n"; -import { useManifestDraftStore } from "@/features/manifest-draft/manifest-draft-store"; - -const server = setupServer( - http.get("http://localhost/api/session", () => - HttpResponse.json({ - session: { - loggedIn: true, - expired: false, - siteUrl: "https://secrets.example.com", - userId: "user", - organizationId: "org", - }, - }), - ), -); - -function renderManager() { - return render( - - new Map(), dedupingInterval: 0 }}> - - - , - ); -} - -function mockUnconfiguredStorage() { - const requests = { initializations: 0, bindings: 0, saves: 0, lists: 0 }; - server.use( - http.get("http://localhost/api/workspaces/demo-entry/secrets", () => { - requests.lists++; - if (!requests.bindings) - return HttpResponse.json( - { error: { code: "INFISICAL_NOT_CONFIGURED", message: "Storage is not connected." } }, - { status: 409 }, - ); - return HttpResponse.json({ - schema: "one-cli/env-list/v1", - env: "dev", - path: "/", - keys: requests.saves ? ["API_TOKEN"] : [], - total: requests.saves, - }); - }), - http.get("http://localhost/api/workspaces/demo-entry/environment", () => - HttpResponse.json({ - revision: "revision-1", - backend: "", - projectId: "", - siteUrl: "", - environments: ["dev"], - }), - ), - http.get("http://localhost/api/workspaces/demo-entry/overview", () => - HttpResponse.json({ present: true, workspace: { name: "demo" } }), - ), - http.get("http://localhost/api/infisical/projects/existing", () => - HttpResponse.json({ - id: "existing", - name: "Existing project", - orgId: "org", - environments: [{ name: "Development", slug: "dev" }], - }), - ), - http.post( - "http://localhost/api/workspaces/demo-entry/environment/bind", - async ({ request }) => { - const body = (await request.json()) as { - revision: string; - create: boolean; - projectId?: string; - }; - expect(body.revision).toBe("revision-1"); - if (!body.create) expect(body.projectId).toBe("existing"); - requests.bindings++; - return HttpResponse.json({ - project_id: body.projectId || "new", - project_name: body.create ? "demo" : "Existing project", - created: body.create, - environments: ["dev"], - }); - }, - ), - http.get("http://localhost/api/infisical/projects", () => - HttpResponse.json([ - { - id: "existing", - name: "Existing project", - orgId: "org", - environments: [{ name: "Development", slug: "dev" }], - }, - ]), - ), - http.post("http://localhost/api/workspaces/demo-entry/manifest/preview", () => - HttpResponse.json({ - revision: "revision-1", - path: "one.manifest.toml", - before: "version = 2\n", - after: 'version = 2\n[env.infisical]\nprojectId = "existing"\n', - }), - ), - http.put("http://localhost/api/workspaces/demo-entry/manifest", async ({ request }) => { - const body = (await request.json()) as { workspace: { environment: { projectId: string } } }; - expect(body.workspace.environment.projectId).toBe("existing"); - requests.bindings++; - return HttpResponse.json({ revision: "revision-2" }); - }), - http.post("http://localhost/api/workspaces/demo-entry/environment/backend/initialize", () => { - requests.initializations++; - return HttpResponse.json({ - schema: "one-cli/workspace-environment/v1", - backend: "infisical", - projectId: "new-id", - projectName: "demo-a3f2", - binding: { - project_id: "new-id", - project_name: "demo-a3f2", - created: true, - requested_name: "demo", - }, - }); - }), - http.post("http://localhost/api/workspaces/demo-entry/secrets", async ({ request }) => { - expect(requests.bindings).toBe(1); - expect(requests.initializations).toBe(0); - expect(await request.json()).toEqual({ key: "API_TOKEN", value: "secret-value" }); - requests.saves++; - return HttpResponse.json({ action: "created", key: "API_TOKEN" }, { status: 201 }); - }), - ); - return requests; -} - -describe("Infisical secrets manager", () => { - beforeAll(async () => { - server.listen({ onUnhandledRequest: "error" }); - await i18n.changeLanguage("en-US"); - }); - afterEach(async () => { - server.resetHandlers(); - useManifestDraftStore.setState({ drafts: {} }); - await i18n.changeLanguage("en-US"); - }); - afterAll(() => server.close()); - - it("lists names without values and reveals only one requested secret", async () => { - let reveals = 0; - server.use( - http.get("http://localhost/api/workspaces/demo-entry/secrets", ({ request }) => { - const url = new URL(request.url); - expect(url.searchParams.get("env")).toBe("dev"); - return HttpResponse.json({ - schema: "one-cli/env-list/v1", - env: "dev", - path: "/", - keys: ["API_TOKEN", "DATABASE_URL"], - total: 2, - }); - }), - http.get("http://localhost/api/workspaces/demo-entry/secrets/API_TOKEN", () => { - reveals += 1; - return HttpResponse.json({ - schema: "one-cli/env-get/v1", - env: "dev", - path: "/", - key: "API_TOKEN", - value: "top-secret", - }); - }), - ); - const user = userEvent.setup(); - renderManager(); - - const row = within(await screen.findByText("API_TOKEN").then((node) => node.closest("tr")!)); - expect(row.getByText("••••••••••••")).toBeDefined(); - expect(screen.queryByText("top-secret")).toBeNull(); - await user.click(row.getByRole("button", { name: "Reveal value" })); - expect(await row.findByText("top-secret")).toBeDefined(); - expect(reveals).toBe(1); - }); - - it("blocks duplicate binding submissions and keeps the pending dialog open", async () => { - const requests = mockUnconfiguredStorage(); - let complete!: () => void; - let submits = 0; - server.use( - http.post("http://localhost/api/workspaces/demo-entry/environment/bind", async () => { - submits++; - await new Promise((resolve) => { - complete = resolve; - }); - requests.bindings++; - return HttpResponse.json({ - project_id: "new", - project_name: "demo", - created: true, - environments: ["dev"], - }); - }), - ); - renderManager(); - const user = userEvent.setup(); - await user.click(await screen.findByRole("button", { name: i18n.t("binding.workspaceTitle") })); - const confirm = await screen.findByRole("button", { name: i18n.t("binding.createAndBind") }); - await waitFor(() => expect(confirm.hasAttribute("disabled")).toBe(false)); - await user.dblClick(confirm); - await waitFor(() => expect(submits).toBe(1)); - expect( - screen.getByRole("button", { name: i18n.t("binding.binding") }).hasAttribute("disabled"), - ).toBe(true); - await user.keyboard("{Escape}"); - expect(screen.getByRole("dialog")).toBeDefined(); - complete(); - await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); - expect(submits).toBe(1); - }); - - it("recovers a created project after a revision conflict without creating again", async () => { - const requests = mockUnconfiguredStorage(); - let creates = 0; - let revision = "revision-1"; - server.use( - http.get("http://localhost/api/workspaces/demo-entry/environment", () => - HttpResponse.json({ revision, projectId: "", environments: ["dev"] }), - ), - http.post( - "http://localhost/api/workspaces/demo-entry/environment/bind", - async ({ request }) => { - const body = (await request.json()) as { - create: boolean; - revision: string; - projectId?: string; - }; - if (body.create) { - creates++; - revision = "revision-2"; - return HttpResponse.json( - { - error: { - code: "SERVE_MANIFEST_CONFLICT", - message: "Configuration changed", - context: { - partial_state: "project_created_binding_unsaved", - project_id: "existing", - }, - }, - }, - { status: 409 }, - ); - } - expect(body).toEqual({ create: false, revision: "revision-2", projectId: "existing" }); - requests.bindings++; - return HttpResponse.json({ - project_id: "existing", - project_name: "demo", - created: false, - environments: ["dev"], - }); - }, - ), - ); - renderManager(); - const user = userEvent.setup(); - await user.click(await screen.findByRole("button", { name: i18n.t("binding.workspaceTitle") })); - const create = await screen.findByRole("button", { name: i18n.t("binding.createAndBind") }); - await waitFor(() => expect(create.hasAttribute("disabled")).toBe(false)); - await user.click(create); - await screen.findByText(i18n.t("binding.conflict")); - const bind = screen.getByRole("button", { name: i18n.t("binding.bindExisting") }); - expect(bind.hasAttribute("disabled")).toBe(true); - await user.click( - within(screen.getByRole("dialog")).getByRole("button", { name: i18n.t("secrets.retry") }), - ); - await waitFor(() => expect(bind.hasAttribute("disabled")).toBe(false)); - await user.click(bind); - await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); - expect(creates).toBe(1); - expect(requests.bindings).toBe(1); - }); - - it("requires pending configuration to be saved or discarded before binding", async () => { - const requests = mockUnconfiguredStorage(); - useManifestDraftStore.getState().stageWorkspaceSection({ - entryId: "demo-entry", - revision: "revision-1", - section: "environment", - initial: {}, - next: { backend: "infisical", projectId: "existing" }, - labels: {}, - }); - renderManager(); - const user = userEvent.setup(); - await user.click(await screen.findByRole("button", { name: i18n.t("binding.workspaceTitle") })); - await screen.findByText(i18n.t("binding.pendingDraft")); - const confirm = screen.getByRole("button", { name: i18n.t("binding.createAndBind") }); - expect(confirm.hasAttribute("disabled")).toBe(true); - expect(requests.bindings).toBe(0); - await user.click(screen.getByRole("button", { name: i18n.t("manifestDraft.discard") })); - await waitFor(() => expect(confirm.hasAttribute("disabled")).toBe(false)); - expect(useManifestDraftStore.getState().drafts["demo-entry"]).toBeUndefined(); - }); - - it("rejects a workspace project without the required dev environment", async () => { - const requests = mockUnconfiguredStorage(); - server.use( - http.get("http://localhost/api/infisical/projects/existing", () => - HttpResponse.json({ - id: "existing", - name: "Existing project", - orgId: "org", - environments: [{ slug: "prod", name: "Production" }], - }), - ), - ); - renderManager(); - const user = userEvent.setup(); - await user.click(await screen.findByRole("button", { name: i18n.t("binding.workspaceTitle") })); - await user.click(screen.getByRole("combobox", { name: i18n.t("binding.method") })); - await user.click(await screen.findByRole("option", { name: i18n.t("binding.existing") })); - await user.click(screen.getByRole("combobox", { name: i18n.t("global.project") })); - await user.click(await screen.findByRole("option", { name: "Existing project" })); - await screen.findByText(i18n.t("binding.devRequired")); - expect( - screen.getByRole("button", { name: i18n.t("binding.bindExisting") }).hasAttribute("disabled"), - ).toBe(true); - expect(requests.bindings).toBe(0); - }); - - it("creates a secret in the selected scope without touching a manifest API", async () => { - let requestBody: unknown; - server.use( - http.get("http://localhost/api/workspaces/demo-entry/secrets", () => - HttpResponse.json({ - schema: "one-cli/env-list/v1", - env: "dev", - path: "/", - keys: [], - total: 0, - }), - ), - http.post("http://localhost/api/workspaces/demo-entry/secrets", async ({ request }) => { - requestBody = await request.json(); - return HttpResponse.json( - { - schema: "one-cli/env-set/v1", - env: "dev", - path: "/", - key: "API_TOKEN", - action: "created", - }, - { status: 201 }, - ); - }), - ); - const user = userEvent.setup(); - renderManager(); - await user.click(await screen.findByRole("button", { name: "Add secret" })); - const dialog = await screen.findByRole("dialog"); - await user.type(within(dialog).getByLabelText("Key"), "api_token"); - await user.type(within(dialog).getByLabelText("Value"), "secret-value"); - await user.click(within(dialog).getByRole("button", { name: "Save secret" })); - - await waitFor(() => expect(requestBody).toEqual({ key: "API_TOKEN", value: "secret-value" })); - }); - - describe.each(["en-US", "zh-CN"])("unconfigured storage (%s)", (locale) => { - let requests: ReturnType; - let user: ReturnType; - - beforeEach(async () => { - await i18n.changeLanguage(locale); - requests = mockUnconfiguredStorage(); - user = userEvent.setup(); - renderManager(); - expect(await screen.findByText(i18n.t("secrets.notConfiguredTitle"))).toBeDefined(); - expect(requests.initializations).toBe(0); - }); - - it("retries the list without initializing storage", async () => { - await user.click(screen.getByRole("button", { name: i18n.t("secrets.retry") })); - await waitFor(() => expect(requests.lists).toBeGreaterThan(1)); - expect(requests.initializations).toBe(0); - expect(requests.saves).toBe(0); - }); - - it("disables adding secrets until storage is explicitly connected", async () => { - expect( - screen.getByRole("button", { name: i18n.t("secrets.add") }).hasAttribute("disabled"), - ).toBe(true); - await user.click(screen.getByRole("button", { name: i18n.t("secrets.add") })); - expect(screen.queryByRole("dialog")).toBeNull(); - expect(requests.initializations).toBe(0); - expect(requests.saves).toBe(0); - }); - - it("opens and closes the connection dialog without creating storage", async () => { - await user.click(screen.getByRole("button", { name: i18n.t("binding.workspaceTitle") })); - const dialog = within(await screen.findByRole("dialog")); - expect(await dialog.findByRole("combobox", { name: i18n.t("binding.method") })).toBeDefined(); - await user.click(dialog.getAllByRole("button", { name: i18n.t("form.close") })[0]); - await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); - expect(requests.initializations).toBe(0); - expect(requests.bindings).toBe(0); - expect(requests.saves).toBe(0); - }); - - it("creates and binds storage only after the explicit confirmation", async () => { - await user.click(screen.getByRole("button", { name: i18n.t("binding.workspaceTitle") })); - const dialog = within(await screen.findByRole("dialog")); - const confirm = await dialog.findByRole("button", { name: i18n.t("binding.createAndBind") }); - await waitFor(() => expect(confirm.hasAttribute("disabled")).toBe(false)); - expect(requests.bindings).toBe(0); - await user.click(confirm); - await waitFor(() => expect(requests.bindings).toBe(1)); - await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); - expect(requests.initializations).toBe(0); - expect( - screen.getAllByRole("button", { name: i18n.t("secrets.add") })[0].hasAttribute("disabled"), - ).toBe(false); - }); - it("saves secrets only after confirming an explicit binding", async () => { - await user.click(screen.getByRole("button", { name: i18n.t("binding.workspaceTitle") })); - const connection = within(await screen.findByRole("dialog")); - await user.click(await connection.findByRole("combobox", { name: i18n.t("binding.method") })); - await user.click(await screen.findByRole("option", { name: i18n.t("binding.existing") })); - const selector = await connection.findByRole("combobox", { name: i18n.t("global.project") }); - await waitFor(() => expect(selector.hasAttribute("disabled")).toBe(false)); - await user.click(selector); - await user.click(await screen.findByRole("option", { name: "Existing project" })); - expect(requests.bindings).toBe(0); - const confirm = connection.getByRole("button", { name: i18n.t("binding.bindExisting") }); - await waitFor(() => expect(confirm.hasAttribute("disabled")).toBe(false)); - await user.click(confirm); - await waitFor(() => expect(requests.bindings).toBe(1)); - await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); - const add = await screen.findAllByRole("button", { name: i18n.t("secrets.add") }); - await user.click(add[0]); - const dialog = within(await screen.findByRole("dialog")); - await user.click(dialog.getByLabelText(i18n.t("secrets.key"))); - await user.paste("API_TOKEN"); - await user.click(dialog.getByLabelText(i18n.t("secrets.value"))); - await user.paste("secret-value"); - expect(requests.initializations).toBe(0); - await user.click(dialog.getByRole("button", { name: i18n.t("secrets.save") })); - await waitFor(() => expect(requests.saves).toBe(1)); - expect(requests.initializations).toBe(0); - await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); - }); - }); -}); diff --git a/apps/dashboard/src/features/services/ServicePanel.test.tsx b/apps/dashboard/src/features/services/ServicePanel.test.tsx deleted file mode 100644 index a52d9e43..00000000 --- a/apps/dashboard/src/features/services/ServicePanel.test.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { act, render, screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { controlService, watchService } from "@/api/services"; -import { useManifestDraftStore } from "@/features/manifest-draft/manifest-draft-store"; -import { mergeService, ServicePanel } from "@/features/services/ServicePanel"; -import { consoleText, renderConsole } from "@/features/services/console"; -import i18n from "@/lib/i18n"; -import type { DevService } from "@/types/api"; -vi.mock("@/api/services", () => ({ controlService: vi.fn(), watchService: vi.fn() })); -const idle: DevService = { - id: "", - project: "web", - environment: "dev", - status: "stopped", - startedAt: "", - logs: [], - endpoints: [], - firstSeq: 1, - nextSeq: 1, -}; -let receive: (state: DevService) => void; -let connection: (connected: boolean) => void; -const close = vi.fn(); -beforeEach(async () => { - vi.resetAllMocks(); - useManifestDraftStore.setState({ drafts: {} }); - await i18n.changeLanguage("en-US"); - vi.mocked(watchService).mockImplementation((_project, _entry, onState, onConnection) => { - receive = onState; - connection = onConnection; - onState(idle); - onConnection(true); - return close; - }); - vi.mocked(controlService).mockResolvedValue({ ...idle, id: "1", status: "preparing" }); -}); -function mount(environment = "dev") { - return render( - , - ); -} -describe("service console", () => { - it("launches the selected environment and keeps stop available with a draft", async () => { - const user = userEvent.setup(); - mount("prod"); - await user.click(screen.getByRole("button", { name: "Start service" })); - expect(controlService).toHaveBeenCalledWith("web", "entry", "start", "prod"); - act(() => - receive({ - ...idle, - id: "1", - status: "running", - environment: "dev", - endpoints: [{ url: "http://localhost:3000/", reachable: true }], - logs: [{ seq: 1, text: "ready\n" }], - nextSeq: 2, - }), - ); - expect(screen.getByText(/This service uses dev/)).toBeTruthy(); - expect(screen.getByRole("link", { name: "Open" }).getAttribute("href")).toBe( - "http://localhost:3000/", - ); - act(() => - useManifestDraftStore.getState().stageWorkspaceSection({ - entryId: "entry", - revision: "x", - section: "environment", - initial: { backend: "infisical", projectId: "old" }, - next: { backend: "infisical", projectId: "new" }, - labels: {}, - }), - ); - expect((screen.getByRole("button", { name: "Restart" }) as HTMLButtonElement).disabled).toBe( - true, - ); - await user.click(screen.getByRole("button", { name: "Stop" })); - expect(controlService).toHaveBeenLastCalledWith("web", "entry", "stop", "prod"); - }); - it("reconnects without duplicating logs, filters text and closes only the subscription", async () => { - const user = userEvent.setup(); - const view = mount(); - const next = { - ...idle, - id: "1", - status: "running" as const, - logs: [ - { seq: 1, text: "hello\n" }, - { seq: 2, text: "world\n" }, - ], - nextSeq: 3, - }; - act(() => { - receive(next); - connection(false); - receive(next); - connection(true); - }); - expect(screen.getByLabelText("Console", { selector: "pre" }).textContent).toBe( - "hello\nworld\n", - ); - await user.type(screen.getByRole("textbox", { name: "Search logs" }), "world"); - expect(screen.getByLabelText("Console", { selector: "pre" }).textContent).toBe("world"); - view.unmount(); - expect(close).toHaveBeenCalled(); - expect(controlService).not.toHaveBeenCalled(); - }); - it("shows failures without claiming the service started, in both languages", async () => { - await i18n.changeLanguage("zh-CN"); - vi.mocked(controlService).mockRejectedValue({ message: "端口占用" }); - const user = userEvent.setup(); - mount(); - await user.click(screen.getByRole("button", { name: "启动服务" })); - await waitFor(() => expect(screen.getByRole("alert").textContent).toContain("端口占用")); - expect(screen.getByText("已停止")).toBeTruthy(); - }); - it("resets logs on a new run and removes expired chunks", () => { - const previous = { - ...idle, - id: "1", - logs: [ - { seq: 1, text: "old" }, - { seq: 2, text: "keep" }, - ], - nextSeq: 3, - }; - expect( - mergeService(previous, { - ...previous, - firstSeq: 2, - logs: [{ seq: 3, text: "new" }], - nextSeq: 4, - }).logs.map((x) => x.text), - ).toEqual(["keep", "new"]); - expect(mergeService(previous, { ...idle, id: "2" }).logs).toEqual([]); - }); - it("renders ANSI text without interpreting HTML or terminal hyperlinks", () => { - const raw = - "\x1b[31m\x1b[0m\x1b]8;;https://evil.example\x07link\x1b]8;;\x07"; - const { container } = render(
{renderConsole(raw)}
); - expect(container.querySelector("script,a")).toBeNull(); - expect(container.textContent).toBe("link"); - expect(consoleText(raw)).toBe("link"); - }); -}); diff --git a/apps/dashboard/src/locales/en-US.json b/apps/dashboard/src/locales/en-US.json index 2c37f01a..4586963f 100644 --- a/apps/dashboard/src/locales/en-US.json +++ b/apps/dashboard/src/locales/en-US.json @@ -423,7 +423,7 @@ }, "ts-library": { "name": "TypeScript library", - "description": "Publishable library with tsdown, vitest, and oxlint/oxfmt" + "description": "Publishable library with tsdown and oxlint/oxfmt" }, "go-lib": { "name": "Go library", @@ -520,17 +520,17 @@ }, "ts-library": { "description": "Create reusable TypeScript modules and packages for publishing to npm.", - "stack": "TypeScript · tsdown · Vitest", + "stack": "TypeScript · tsdown", "feature1": "Library builds and type declarations with tsdown", - "feature2": "Unit tests and coverage with Vitest", + "feature2": "ESM and CommonJS outputs", "feature3": "Linting, formatting, and publishing scripts" }, "go-lib": { "description": "Create reusable Go modules with a public package layout for other projects to import.", "stack": "Go · Go modules · go-task", "feature1": "Public API starter in pkg/greeter", - "feature2": "Example package and unit tests", - "feature3": "Build, format, vet, and test tasks" + "feature2": "Dependency management with Go modules", + "feature3": "Build, format, and vet tasks" }, "electron-app": { "description": "Create desktop applications with an Electron main process and a React user interface.", diff --git a/apps/dashboard/src/locales/zh-CN.json b/apps/dashboard/src/locales/zh-CN.json index 07fc81f3..d3c71d6f 100644 --- a/apps/dashboard/src/locales/zh-CN.json +++ b/apps/dashboard/src/locales/zh-CN.json @@ -423,7 +423,7 @@ }, "ts-library": { "name": "TypeScript 通用库", - "description": "通用库模板,适合发包场景,内置 tsdown + vitest + oxlint/oxfmt" + "description": "通用库模板,适合发包场景,内置 tsdown + oxlint/oxfmt" }, "go-lib": { "name": "Go 通用库", @@ -520,17 +520,17 @@ }, "ts-library": { "description": "开发可复用的 TypeScript 模块,适合共享工具库和发布到 npm 的包。", - "stack": "TypeScript · tsdown · Vitest", + "stack": "TypeScript · tsdown", "feature1": "tsdown 库构建与类型声明生成", - "feature2": "Vitest 单元测试与覆盖率", + "feature2": "ESM 与 CommonJS 双格式输出", "feature3": "代码检查、格式化与发包脚本" }, "go-lib": { "description": "开发可被其他项目导入的 Go 模块,使用清晰的公共包目录组织可复用代码。", "stack": "Go · Go modules · go-task", "feature1": "pkg/greeter 公共 API 起点", - "feature2": "示例包与单元测试", - "feature3": "构建、格式化、静态检查与测试任务" + "feature2": "Go modules 依赖管理", + "feature3": "构建、格式化与静态检查任务" }, "electron-app": { "description": "使用 Electron 主进程和 React 界面构建桌面应用,适合桌面工具与业务客户端。", diff --git a/apps/dashboard/src/pages/Overview.test.tsx b/apps/dashboard/src/pages/Overview.test.tsx deleted file mode 100644 index fc1f395d..00000000 --- a/apps/dashboard/src/pages/Overview.test.tsx +++ /dev/null @@ -1,449 +0,0 @@ -import { render, screen, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { HttpResponse, http } from "msw"; -import { setupServer } from "msw/node"; -import { MemoryRouter, useLocation } from "react-router-dom"; -import useSWR, { SWRConfig } from "swr"; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { - getOverview, - overviewKeyFor, - projectSettingsKey, - workspaceEnvironmentKey, -} from "@/api/workspace"; -import { environmentFromSearch } from "@/features/environment-context/environment"; -import { - manifestDraftKey, - useManifestDraftStore, -} from "@/features/manifest-draft/manifest-draft-store"; -import i18n from "@/lib/i18n"; -import { Overview } from "@/pages/Overview"; -import type { - BackendSpec, - Overview as OverviewPayload, - ProjectSettingsResponse, -} from "@/types/api"; - -const server = setupServer( - http.get(/\/api\/workspaces?(?:\/[^/]+)?\/services$/, () => HttpResponse.json({ services: [] })), -); - -const catalogBackends: BackendSpec[] = [ - { - id: "env/infisical", - domain: "env", - name: "infisical", - capabilities: ["env-load"], - project: { configurable: false }, - }, -]; - -const overview: OverviewPayload = { - schema: "one-cli/workspace-overview/v1", - present: true, - root: "/workspace/demo", - workspace: { - id: "demo", - name: "demo", - manifestVersion: 1, - defaultEnvironment: "dev", - environments: ["dev", "staging", "prod"], - domains: { env: "infisical" }, - }, - projects: [ - { - name: "web", - relativeDir: "apps/web", - kind: "app", - toolchain: "node", - domains: { env: "infisical" }, - }, - { - name: "api", - relativeDir: "services/api", - kind: "service", - toolchain: "go", - domains: { env: "infisical" }, - }, - { - name: "shared", - relativeDir: "packages/shared", - kind: "package", - toolchain: "node", - domains: { env: "infisical" }, - }, - ], -}; - -const webSettings: ProjectSettingsResponse = { - schema: "one-cli/workspace-project/v1", - root: "/workspace/demo", - environment: "dev", - revision: "sha256:test-revision", - project: { - name: "web", - relativeDir: "apps/web", - kind: "app", - toolchain: "node", - packageManager: "pnpm", - devAvailable: true, - build: { - command: "pnpm run build", - source: "package.json#scripts.build", - status: "ready", - }, - availableEnvironments: ["dev", "staging", "prod"], - environment: { - backend: "infisical", - path: "/apps/web", - inherits: true, - }, - }, -}; - -const OverviewHarness: React.FC<{ - data: OverviewPayload; - workspaceEntryId?: string; - readOnly?: boolean; - revalidateOverview?: boolean; -}> = ({ data, workspaceEntryId, readOnly, revalidateOverview }) => { - const { search } = useLocation(); - const environment = environmentFromSearch(search); - const current = useSWR( - overviewKeyFor(workspaceEntryId, environment), - revalidateOverview ? () => getOverview(workspaceEntryId, environment) : null, - { - fallbackData: data, - revalidateOnMount: false, - }, - ); - - return ( - <> - {search} - - - ); -}; - -function renderOverview( - data: OverviewPayload = overview, - workspaceEntryId?: string, - readOnly?: boolean, - revalidateOverview?: boolean, - environment = "dev", -) { - return render( - new Map() }}> - - - - , - ); -} - -async function openProjectSettings() { - return screen.findByRole("region", { name: "Project settings" }); -} - -async function openProjectSettingsTab( - user: ReturnType, - tabName: "Environment", -) { - const settings = await openProjectSettings(); - await user.click(within(settings).getByRole("tab", { name: tabName })); - return settings; -} - -async function openWorkspaceSettingsDialog(user: ReturnType) { - const inspector = await openProjectSettings(); - await user.click(within(inspector).getByRole("button", { name: "Workspace settings" })); - return screen.findByRole("dialog", { name: "Workspace settings" }); -} - -async function openWorkspaceEnvironmentSettings(user: ReturnType) { - const dialog = await openWorkspaceSettingsDialog(user); - return within(dialog).findByRole("region", { name: "Workspace environment" }); -} - -describe("workspace overview Profile-only configuration", () => { - beforeAll(async () => { - server.listen({ onUnhandledRequest: "error" }); - await i18n.changeLanguage("en-US"); - }); - beforeEach(() => { - server.use( - http.get("http://localhost/api/catalog", () => - HttpResponse.json({ schema: "one-cli/catalog/v1", backends: catalogBackends }), - ), - http.get("http://localhost/api/workspace/environment", ({ request }) => - HttpResponse.json({ - schema: "one-cli/workspace-profile/v1", - root: "/workspace/demo", - environment: new URL(request.url).searchParams.get("env") ?? "", - domain: "env", - backend: "infisical", - configurable: false, - }), - ), - http.get("http://localhost/api/workspaces/:entryId/environment", ({ request }) => - HttpResponse.json({ - schema: "one-cli/workspace-profile/v1", - root: "/workspace/demo", - environment: new URL(request.url).searchParams.get("env") ?? "", - domain: "env", - backend: "infisical", - configurable: false, - }), - ), - http.get("http://localhost/api/workspace/secrets", () => - HttpResponse.json({ - schema: "one-cli/env-list/v1", - env: "dev", - path: "/", - keys: [], - total: 0, - }), - ), - http.get("http://localhost/api/workspaces/:entryId/secrets", () => - HttpResponse.json({ - schema: "one-cli/env-list/v1", - env: "dev", - path: "/", - keys: [], - total: 0, - }), - ), - http.get("http://localhost/api/workspace/projects/:projectName", ({ params }) => - HttpResponse.json({ - ...webSettings, - project: { - ...webSettings.project, - name: String(params.projectName), - }, - }), - ), - http.get("http://localhost/api/workspaces/:entryId/projects/:projectName", ({ params }) => - HttpResponse.json({ - ...webSettings, - project: { - ...webSettings.project, - name: String(params.projectName), - }, - }), - ), - ); - }); - afterEach(() => { - useManifestDraftStore.getState().clearWorkspace(); - useManifestDraftStore.getState().clearWorkspace("demo-entry"); - server.resetHandlers(); - vi.restoreAllMocks(); - }); - afterAll(() => server.close()); - - it("creates a project from the empty workspace and selects it after refreshing", async () => { - const user = userEvent.setup(); - let created = false; - server.use( - http.get("http://localhost/api/project-templates", () => - HttpResponse.json({ - templates: [ - { - id: "react-spa", - name: "React", - description: "React", - category: "frontend", - directory: "apps", - toolchain: "node", - }, - ], - }), - ), - http.post("http://localhost/api/workspaces/demo-entry/projects", async ({ request }) => { - expect(await request.json()).toEqual({ name: "web", templateId: "react-spa" }); - created = true; - return HttpResponse.json( - { name: "web", relativeDir: "apps/web", templateId: "react-spa" }, - { status: 201 }, - ); - }), - http.get("http://localhost/api/workspaces/demo-entry/overview", () => - HttpResponse.json({ ...overview, projects: created ? [overview.projects![0]] : [] }), - ), - http.get("http://localhost/api/workspaces", () => HttpResponse.json({ workspaces: [] })), - ); - renderOverview({ ...overview, projects: [] }, "demo-entry", false, true); - const navigation = screen.getByRole("navigation", { name: "Workspace projects" }); - await user.click(within(navigation).getByRole("button", { name: "New project" })); - const dialog = await screen.findByRole("dialog", { name: "New project" }); - await user.type(within(dialog).getByLabelText("Project name"), "web"); - await user.click(within(dialog).getByRole("button", { name: "Create project" })); - const project = await within(navigation).findByRole("button", { name: "web apps/web" }); - expect(project.getAttribute("aria-current")).toBe("page"); - expect(screen.queryByRole("dialog", { name: "New project" })).toBeNull(); - }); - - it("hides project creation for an identity-conflicted workspace", () => { - renderOverview({ ...overview, projects: [] }, "demo-entry", true); - expect(screen.queryByRole("button", { name: "New project" })).toBeNull(); - }); - - it("shows projects in a persistent left-hand list with project settings tabs", async () => { - renderOverview(); - - const settings = await openProjectSettings(); - expect(within(settings).getByRole("button", { name: "web apps/web" })).toBeDefined(); - expect(within(settings).getByRole("button", { name: "api services/api" })).toBeDefined(); - expect(within(settings).getByRole("button", { name: "shared packages/shared" })).toBeDefined(); - expect( - within(settings).getByRole("button", { name: "web apps/web" }).getAttribute("aria-current"), - ).toBe("page"); - expect(within(settings).queryByText("Manifest draft")).toBeNull(); - expect(within(settings).getByRole("tab", { name: "Overview" })).toBeDefined(); - expect(within(settings).getByRole("tab", { name: "Environment" })).toBeDefined(); - expect(within(settings).queryByRole("tab", { name: "Deploy" })).toBeNull(); - expect(within(settings).queryByRole("tab", { name: "Container" })).toBeNull(); - }); - - it("removes the priority queue and local Profile notice from the Workspace page", async () => { - const user = userEvent.setup(); - renderOverview({ - ...overview, - workspace: { ...overview.workspace!, domains: { env: "infisical" } }, - }); - - expect(screen.queryByText("Resolve first")).toBeNull(); - expect( - screen.queryByText( - "Profiles stay in machine-local configuration; credentials never enter the manifest.", - ), - ).toBeNull(); - const dialog = await openWorkspaceSettingsDialog(user); - await user.click(within(dialog).getByRole("tab", { name: "Infisical secrets" })); - expect(within(dialog).getByRole("tabpanel", { name: "Infisical secrets" })).toBeDefined(); - }); - - it("starts with the project inspector without the summary header or command card", async () => { - renderOverview(); - expect(screen.queryByRole("heading", { level: 1, name: "demo" })).toBeNull(); - expect(screen.queryByText("/workspace/demo")).toBeNull(); - expect(screen.queryByText("one dev")).toBeNull(); - expect(await screen.findByRole("region", { name: "Project settings" })).toBeDefined(); - }); - - it("uses environment-specific SWR keys for every workspace projection", () => { - expect(overviewKeyFor("demo-entry", "dev")).toBe("/workspaces/demo-entry/overview?env=dev"); - expect(workspaceEnvironmentKey("demo-entry", "staging")).toBe( - "/workspaces/demo-entry/environment?env=staging", - ); - expect(projectSettingsKey("web app", "demo-entry", "prod")).toBe( - "/workspaces/demo-entry/projects/web%20app?env=prod", - ); - expect(projectSettingsKey("web", undefined, "dev")).not.toBe( - projectSettingsKey("web", undefined, "prod"), - ); - }); - - it("shows Infisical without a backend switch", async () => { - const user = userEvent.setup(); - renderOverview(overview, "demo-entry"); - const region = await openWorkspaceEnvironmentSettings(user); - expect(within(region).getByText("Infisical")).toBeDefined(); - expect(within(region).queryByRole("combobox", { name: "Backend" })).toBeNull(); - expect(useManifestDraftStore.getState().drafts[manifestDraftKey("demo-entry")]).toBeUndefined(); - }); - - it("shows project metadata without retired manifest editing fields", async () => { - renderOverview(); - const inspector = await openProjectSettings(); - const command = (await within(inspector).findByLabelText("Build command")) as HTMLInputElement; - expect(command.value).toBe("pnpm run build"); - expect(command.readOnly).toBe(true); - expect(within(inspector).getByText("pnpm")).toBeDefined(); - expect(within(inspector).queryByLabelText("Build version")).toBeNull(); - expect(within(inspector).queryByLabelText("Development URL")).toBeNull(); - expect(useManifestDraftStore.getState().drafts[manifestDraftKey()]).toBeUndefined(); - }); - - it.each([ - { status: "missing" as const, placeholder: "No build task configured" }, - { status: "invalid" as const, placeholder: "Unable to read build configuration" }, - ])( - "keeps project settings usable when the build task is $status", - async ({ status, placeholder }) => { - server.use( - http.get("http://localhost/api/workspace/projects/web", () => - HttpResponse.json({ - ...webSettings, - project: { - ...webSettings.project, - build: { source: "Taskfile.yml#tasks.build", status }, - }, - }), - ), - ); - renderOverview(); - const inspector = await openProjectSettings(); - const command = (await within(inspector).findByLabelText( - "Build command", - )) as HTMLInputElement; - expect(command.value).toBe(""); - expect(command.placeholder).toBe(placeholder); - expect(command.readOnly).toBe(true); - expect(within(inspector).getByText(/one build reads Taskfile.yml#tasks.build/)).toBeDefined(); - expect(within(inspector).queryByLabelText("Build version")).toBeNull(); - }, - ); - - it("shows configured task dependencies and cache settings without claiming cache hits", async () => { - server.use( - http.get("http://localhost/api/workspace/projects/web", () => - HttpResponse.json({ - ...webSettings, - project: { - ...webSettings.project, - tasks: { - status: "ready", - entries: [ - { - name: "//apps/web:build", - source: "apps/web/mise.toml", - depends: ["//packages/lib:build"], - outputs: ["dist"], - cacheEnabled: true, - }, - ], - }, - }, - }), - ), - ); - renderOverview(); - const inspector = await openProjectSettings(); - const tasks = await within(inspector).findByRole("region", { name: "Configured tasks" }); - expect(within(tasks).getByText("//apps/web:build")).toBeDefined(); - expect(within(tasks).getByText("Dependencies: //packages/lib:build")).toBeDefined(); - expect(within(tasks).getByText("Artifact caching is configured.")).toBeDefined(); - expect(within(tasks).queryByRole("button")).toBeNull(); - }); - - it("keeps project environment configuration separate from remote secret operations", async () => { - const user = userEvent.setup(); - renderOverview(); - const inspector = await openProjectSettingsTab(user, "Environment"); - const backendConfig = within(inspector).getByTestId("environment-settings-grid"); - - expect(within(backendConfig).queryByLabelText("Project profile")).toBeNull(); - expect(within(inspector).queryByRole("button", { name: "Save local binding" })).toBeNull(); - }); -}); diff --git a/apps/dashboard/src/pages/ProjectTemplates.test.tsx b/apps/dashboard/src/pages/ProjectTemplates.test.tsx deleted file mode 100644 index 3a1429d0..00000000 --- a/apps/dashboard/src/pages/ProjectTemplates.test.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { act, render, screen, waitFor, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { HttpResponse, http } from "msw"; -import { setupServer } from "msw/node"; -import { MemoryRouter } from "react-router-dom"; -import { SWRConfig } from "swr"; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; -import { App } from "@/App"; -import { ProjectTemplates } from "@/pages/ProjectTemplates"; -import i18n from "@/lib/i18n"; - -const registry = JSON.parse( - readFileSync(resolve(process.cwd(), "../../packages/templates/registry.json"), "utf8"), -); -const templates = registry.templates.map( - (template: { - id: string; - name: string; - description: string; - category: "frontend" | "backend" | "library"; - toolchain: string; - }) => ({ - ...template, - directory: { frontend: "apps", backend: "services", library: "packages" }[template.category], - }), -); -const server = setupServer( - http.get("http://localhost/api/project-templates", () => HttpResponse.json({ templates })), - http.get("http://localhost/api/workspaces", () => HttpResponse.json({ workspaces: [] })), - http.get("http://localhost/api/session", () => - HttpResponse.json({ session: { loggedIn: false } }), - ), -); -function show(withApp = false) { - return render( - new Map(), shouldRetryOnError: false }}> - - {withApp ? : } - - , - ); -} -beforeAll(() => server.listen({ onUnhandledRequest: "error" })); -beforeEach(async () => { - await i18n.changeLanguage("en-US"); -}); -afterEach(() => server.resetHandlers()); -afterAll(() => server.close()); - -describe("project template catalog", () => { - it.each(["en-US", "zh-CN"])("introduces every registered template in %s", async (locale) => { - await i18n.changeLanguage(locale); - show(); - expect(await screen.findAllByRole("article")).toHaveLength(templates.length); - for (const template of templates) { - const name = i18n.t(`projectCreate.templates.${template.id}.name`); - const card = screen.getByRole("article", { name }); - for (const key of ["description", "stack", "feature1", "feature2", "feature3"]) { - const value = i18n.getResource( - locale, - "translation", - `templateCatalog.templates.${template.id}.${key}`, - ); - expect(typeof value).toBe("string"); - expect(value.length).toBeGreaterThan(0); - expect(within(card).getByText(value)).toBeDefined(); - } - expect(within(card).getByText(`${template.directory}/`)).toBeDefined(); - } - expect( - screen - .getByRole("link", { name: i18n.t("templateCatalog.chooseWorkspace") }) - .getAttribute("href"), - ).toBe("/?env=staging"); - }); - - it("combines category and feature search, and resets an empty result", async () => { - const user = userEvent.setup(); - show(); - await screen.findAllByRole("article"); - await user.click(screen.getByRole("button", { name: /Services/ })); - expect(screen.getAllByRole("article")).toHaveLength( - templates.filter((template: { category: string }) => template.category === "backend").length, - ); - await user.type( - screen.getByRole("textbox", { name: "Search templates, stacks, or features…" }), - " DRIZZLE ", - ); - expect(screen.getAllByRole("article")).toHaveLength(1); - expect(screen.getByRole("article", { name: "NestJS API service" })).toBeDefined(); - await user.click(screen.getByRole("button", { name: /Libraries/ })); - expect(screen.queryAllByRole("article")).toHaveLength(0); - expect(screen.getByRole("heading", { name: "No matching templates" })).toBeDefined(); - await user.click(screen.getByRole("button", { name: "Clear filters" })); - expect(screen.getAllByRole("article")).toHaveLength(templates.length); - }); - - it("updates introductions when the language changes", async () => { - show(); - await screen.findByRole("article", { name: "React single-page application" }); - await act(async () => { - await i18n.changeLanguage("zh-CN"); - }); - expect(await screen.findByRole("article", { name: "React 单页应用" })).toBeDefined(); - expect(screen.queryByText("Client-side routing and error boundaries")).toBeNull(); - expect(screen.getByText("客户端路由与错误边界")).toBeDefined(); - }); - - it("recovers from a catalog error", async () => { - server.use( - http.get( - "http://localhost/api/project-templates", - () => new HttpResponse(null, { status: 500 }), - ), - ); - const user = userEvent.setup(); - show(); - expect(await screen.findByRole("alert")).toBeDefined(); - server.use( - http.get("http://localhost/api/project-templates", () => HttpResponse.json({ templates })), - ); - await user.click(screen.getByRole("button", { name: "Retry" })); - expect(await screen.findAllByRole("article")).toHaveLength(templates.length); - }); - - it("shows the catalog empty state", async () => { - server.use( - http.get("http://localhost/api/project-templates", () => - HttpResponse.json({ templates: [] }), - ), - ); - show(); - expect( - await screen.findByRole("heading", { name: i18n.t("projectCreate.noTemplates") }), - ).toBeDefined(); - expect(screen.queryByRole("textbox")).toBeNull(); - }); - - it("exposes the page through the brand menu and breadcrumb", async () => { - const user = userEvent.setup(); - show(true); - await screen.findByRole("heading", { name: "Project templates", level: 1 }); - const breadcrumb = screen.getByRole("navigation", { name: "breadcrumb" }); - expect(within(breadcrumb).getByText("Project templates")).toBeDefined(); - await user.click(screen.getByRole("button", { name: "One CLI navigation menu" })); - const item = screen.getByRole("menuitem", { name: "Project templates" }); - expect(item.getAttribute("href")).toBe("/templates?env=staging"); - expect(item.getAttribute("aria-current")).toBe("page"); - await user.click(item); - await waitFor(() => expect(screen.queryByRole("menu")).toBeNull()); - expect(screen.getByRole("heading", { name: "Project templates", level: 1 })).toBeDefined(); - }); -}); diff --git a/apps/dashboard/src/pages/WorkspaceHome.test.tsx b/apps/dashboard/src/pages/WorkspaceHome.test.tsx deleted file mode 100644 index 88667d5f..00000000 --- a/apps/dashboard/src/pages/WorkspaceHome.test.tsx +++ /dev/null @@ -1,256 +0,0 @@ -import { render, screen, waitFor, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { HttpResponse, http } from "msw"; -import { setupServer } from "msw/node"; -import { MemoryRouter } from "react-router-dom"; -import { SWRConfig } from "swr"; -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; -import i18n from "@/lib/i18n"; -import { WorkspaceHome } from "@/pages/WorkspaceHome"; -import type { WorkspaceRegistryEntry, WorkspacesResponse } from "@/types/api"; - -const server = setupServer(); - -const workspaces: WorkspaceRegistryEntry[] = [ - { - entryId: "alpha-entry", - id: "alpha-a1b2c3", - name: "Alpha", - root: "/workspaces/alpha", - status: "ready", - projectCount: 3, - lastSeenAt: "2026-08-30T09:00:00Z", - }, - { - entryId: "missing-entry", - id: "missing-a1b2c3", - name: "Missing", - root: "/workspaces/missing", - status: "missing", - projectCount: 0, - lastSeenAt: "2026-08-29T09:00:00Z", - }, - { - entryId: "invalid-entry", - id: "invalid-a1b2c3", - name: "Invalid", - root: "/workspaces/invalid", - status: "invalid", - projectCount: 0, - lastSeenAt: "2026-08-28T09:00:00Z", - }, - { - entryId: "identity-missing-entry", - name: "Identity missing", - root: "/workspaces/identity-missing", - status: "identity-missing", - projectCount: 1, - lastSeenAt: "2026-08-27T09:00:00Z", - }, - { - entryId: "identity-conflict-entry", - id: "shared-a1b2c3", - name: "Identity conflict", - root: "/workspaces/identity-conflict", - status: "identity-conflict", - projectCount: 4, - lastSeenAt: "2026-08-26T09:00:00Z", - }, -]; - -function renderHome(path = "/") { - return render( - new Map(), - dedupingInterval: 10_000, - shouldRetryOnError: false, - }} - > - - - - , - ); -} - -beforeAll(async () => { - server.listen({ onUnhandledRequest: "error" }); - await i18n.changeLanguage("en-US"); -}); - -afterEach(() => server.resetHandlers()); -afterAll(() => server.close()); - -describe("WorkspaceHome", () => { - it("shows an explicit loading state while the registry is being read", () => { - let releaseRequest = () => {}; - const pending = new Promise((resolve) => { - releaseRequest = resolve; - }); - server.use( - http.get("http://localhost/api/workspaces", async () => { - await pending; - return HttpResponse.json({ schema: "one-cli/workspaces/v1", workspaces: [] }); - }), - ); - - renderHome(); - - expect(screen.getByRole("status").textContent).toContain("Loading Workspaces"); - releaseRequest(); - }); - - it("shows every registered Workspace with project totals and detection timestamps", async () => { - server.use( - http.get("http://localhost/api/workspaces", () => - HttpResponse.json({ - schema: "one-cli/workspaces/v1", - currentEntryId: "alpha-entry", - workspaces, - } satisfies WorkspacesResponse), - ), - ); - - renderHome("/?env=staging"); - - expect(await screen.findByRole("heading", { name: "Workspaces" })).toBeDefined(); - - const alpha = screen.getByRole("link", { name: /Alpha/ }); - expect(alpha.getAttribute("href")).toBe("/workspace/alpha-entry?env=staging"); - expect(within(alpha).getByText("Projects")).toBeDefined(); - expect(within(alpha).getByText("3")).toBeDefined(); - expect(within(alpha).getByText(/^Last detected ·/)).toBeDefined(); - expect(alpha.querySelector('time[datetime="2026-08-30T09:00:00Z"]')).not.toBeNull(); - - for (const name of ["Missing", "Invalid", "Identity missing", "Identity conflict"]) { - expect(screen.getByRole("link", { name: new RegExp(name) })).toBeDefined(); - } - expect(within(screen.getByRole("link", { name: /Missing/ })).getByText("-")).toBeDefined(); - expect(within(screen.getByRole("link", { name: /Invalid/ })).getByText("-")).toBeDefined(); - }); - - it("explains when the Workspace registry cannot be loaded", async () => { - server.use( - http.get("http://localhost/api/workspaces", () => - HttpResponse.json( - { - schema: "one-cli/error/v1", - error: { - code: "WORKSPACE_REGISTRY_READ_FAILED", - message: "Registry offline", - context: {}, - remediation: [], - }, - }, - { status: 500 }, - ), - ), - ); - - renderHome(); - - const alert = await screen.findByRole("alert"); - expect(within(alert).getByRole("heading", { name: "Could not load Workspaces" })).toBeDefined(); - expect(within(alert).getByText("Registry offline")).toBeDefined(); - expect(within(alert).getByRole("button", { name: "Retry" })).toBeDefined(); - }); - - it("explains how to register the first Workspace", async () => { - server.use( - http.get("http://localhost/api/workspaces", () => - HttpResponse.json({ - schema: "one-cli/workspaces/v1", - workspaces: [], - } satisfies WorkspacesResponse), - ), - ); - - renderHome(); - - expect(await screen.findByRole("heading", { name: "Workspaces" })).toBeDefined(); - expect(screen.getByRole("heading", { name: "No Workspaces yet" })).toBeDefined(); - expect(screen.getByText(/Run one create to create a Workspace/)).toBeDefined(); - }); -}); - -describe("Workspace discovery and recovery", () => { - function serveRegistry() { - server.use( - http.get("http://localhost/api/workspaces", () => - HttpResponse.json({ schema: "one-cli/workspaces/v1", workspaces }), - ), - ); - } - - // This multi-step jsdom interaction shares CI CPUs with the cold Go build. - it("combines path search with attention filtering and restores the list on clear", async () => { - serveRegistry(); - const user = userEvent.setup(); - renderHome("/?env=staging"); - await screen.findByRole("link", { name: /Alpha/ }); - const search = screen.getByRole("textbox", { name: "Search name, path or ID…" }); - // The case/whitespace filter assertion only needs the final query. Pasting - // avoids a full workspace-card render for every character on CI runners. - await user.click(search); - await user.paste(" /WORKSPACES/ALPHA "); - expect(screen.getAllByRole("article")).toHaveLength(1); - expect(screen.getByRole("link", { name: /Alpha/ }).getAttribute("href")).toBe( - "/workspace/alpha-entry?env=staging", - ); - await user.click(screen.getByRole("button", { name: /Needs attention/ })); - expect(screen.getByText("No matching workspaces")).toBeDefined(); - await user.click(screen.getByRole("button", { name: "Clear filters" })); - expect(document.activeElement).toBe(search); - expect(screen.getAllByRole("article")).toHaveLength(5); - await user.click(screen.getByRole("button", { name: /Needs attention/ })); - expect(screen.getAllByRole("article")).toHaveLength(4); - expect(screen.queryByRole("link", { name: /Alpha/ })).toBeNull(); - }, 10_000); - - it("keeps existing workspaces during a failed refresh and supports retry", async () => { - serveRegistry(); - const user = userEvent.setup(); - renderHome(); - await screen.findByRole("link", { name: /Alpha/ }); - server.use( - http.get("http://localhost/api/workspaces", () => - HttpResponse.json({ error: { message: "Registry offline" } }, { status: 500 }), - ), - ); - await user.click(screen.getByRole("button", { name: "Refresh" })); - const alert = await screen.findByRole("alert"); - expect(screen.getAllByRole("article")).toHaveLength(5); - serveRegistry(); - await user.click(within(alert).getByRole("button", { name: "Retry" })); - await waitFor(() => expect(screen.queryByRole("alert")).toBeNull()); - expect(screen.getAllByRole("article")).toHaveLength(5); - }); - - it("keeps a failed removal open and removes only the selected workspace on retry", async () => { - serveRegistry(); - const user = userEvent.setup(); - renderHome(); - await screen.findByRole("link", { name: /Alpha/ }); - server.use( - http.delete("http://localhost/api/workspaces/alpha-entry", () => - HttpResponse.json({ error: { message: "Registry is busy" } }, { status: 500 }), - ), - ); - await user.click(screen.getByRole("button", { name: "Remove Alpha" })); - const dialog = await screen.findByRole("alertdialog"); - await user.click(within(dialog).getByRole("button", { name: "Remove Alpha" })); - expect((await within(dialog).findByRole("alert")).textContent).toContain("Registry is busy"); - expect(screen.getAllByRole("article", { hidden: true })).toHaveLength(5); - server.use( - http.delete( - "http://localhost/api/workspaces/alpha-entry", - () => new HttpResponse(null, { status: 204 }), - ), - ); - await user.click(within(dialog).getByRole("button", { name: "Remove Alpha" })); - await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull()); - expect(screen.queryByRole("link", { name: /Alpha/ })).toBeNull(); - expect(screen.getAllByRole("article")).toHaveLength(4); - }); -}); diff --git a/apps/dashboard/src/router/routes.test.tsx b/apps/dashboard/src/router/routes.test.tsx deleted file mode 100644 index e52d521e..00000000 --- a/apps/dashboard/src/router/routes.test.tsx +++ /dev/null @@ -1,395 +0,0 @@ -import { render, screen, waitFor, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { HttpResponse, http } from "msw"; -import { setupServer } from "msw/node"; -import type React from "react"; -import { MemoryRouter, useLocation } from "react-router-dom"; -import { SWRConfig } from "swr"; -import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -import i18n from "@/lib/i18n"; -import { App } from "@/App"; -import { TopBar } from "@/components/TopBar"; -import { AppRoutes } from "@/router/routes"; -import type { - BackendSpec, - Overview, - WorkspaceRegistryEntry, - WorkspacesResponse, -} from "@/types/api"; - -const server = setupServer( - http.get(/\/api\/workspaces?(?:\/[^/]+)?\/services$/, () => HttpResponse.json({ services: [] })), - http.get("http://localhost/api/session", () => - HttpResponse.json({ session: { loggedIn: false, expired: false } }), - ), -); - -const alpha: WorkspaceRegistryEntry = { - entryId: "alpha-entry", - id: "alpha-a1b2c3", - name: "Alpha", - root: "/workspaces/alpha", - status: "ready", - projectCount: 1, - lastSeenAt: "2026-08-29T09:00:00Z", -}; - -const beta: WorkspaceRegistryEntry = { - entryId: "beta-entry", - id: "beta-d4e5f6", - name: "Beta", - root: "/workspaces/beta", - status: "ready", - projectCount: 2, - lastSeenAt: "2026-08-30T09:00:00Z", -}; - -const broken: WorkspaceRegistryEntry = { - entryId: "broken-entry", - id: "broken-aabbcc", - name: "Broken", - root: "/workspaces/missing", - status: "missing", - projectCount: 0, - lastSeenAt: "2026-08-28T09:00:00Z", -}; - -const infisicalBackend: BackendSpec = { - id: "env/infisical", - domain: "env", - name: "infisical", - capabilities: ["env-load"], - project: { configurable: false }, -}; - -function workspaceOverview(workspace: WorkspaceRegistryEntry): Overview { - return { - schema: "one-cli/workspace-overview/v1", - present: true, - root: workspace.root, - workspace: { - id: workspace.id, - name: workspace.name, - manifestVersion: 1, - environments: ["dev"], - defaultEnvironment: "dev", - domains: { env: "infisical" }, - }, - projects: [ - { - name: `${workspace.name.toLowerCase()}-web`, - relativeDir: "apps/web", - kind: "app", - toolchain: "node", - domains: { env: "infisical" }, - }, - ], - }; -} - -const LocationProbe: React.FC = () => { - const location = useLocation(); - return ( - <> - {location.pathname} - {location.search} - - ); -}; - -function renderDashboard(path = "/") { - return render( - new Map(), dedupingInterval: 10_000 }}> - - -
- -
- -
-
, - ); -} - -function registerCatalogHandler() { - server.use( - http.get("http://localhost/api/catalog", () => - HttpResponse.json({ schema: "one-cli/catalog/v1", backends: [] }), - ), - http.get("http://localhost/api/workspaces/:entryId/environment", ({ params, request }) => { - const entry = params.entryId === beta.entryId ? beta : alpha; - return HttpResponse.json({ - schema: "one-cli/workspace-environment/v1", - revision: "sha256:fixture", - root: entry.root, - environment: new URL(request.url).searchParams.get("env") ?? "dev", - domain: "env", - backend: "infisical", - configurable: false, - }); - }), - http.get( - "http://localhost/api/workspaces/:entryId/projects/:projectName", - ({ params, request }) => { - const entry = params.entryId === beta.entryId ? beta : alpha; - const projectName = String(params.projectName); - return HttpResponse.json({ - schema: "one-cli/workspace-project/v1", - root: entry.root, - environment: new URL(request.url).searchParams.get("env") ?? "dev", - revision: "sha256:route-test", - project: { - name: projectName, - relativeDir: "apps/web", - kind: "app", - toolchain: "node", - packageManager: "pnpm", - buildVersion: "1.0.0", - devAvailable: true, - availableEnvironments: ["dev", "staging", "prod"], - environment: { - backend: "infisical", - path: ".env", - inherits: true, - disabled: false, - keys: [], - }, - container: { enabled: false }, - deploy: { compatibleTargets: [] }, - }, - }); - }, - ), - ); -} - -function registerSettingsHandlers() { - server.use( - http.get("http://localhost/api/workspaces", () => - HttpResponse.json({ - schema: "one-cli/workspaces/v1", - workspaces: [], - } satisfies WorkspacesResponse), - ), - http.get("http://localhost/api/catalog", () => - HttpResponse.json({ - schema: "one-cli/catalog/v1", - backends: [infisicalBackend], - }), - ), - ); -} - -describe("multi-workspace routing", () => { - beforeAll(async () => { - server.listen({ onUnhandledRequest: "error" }); - await i18n.changeLanguage("en-US"); - }); - afterEach(() => { - server.resetHandlers(); - vi.restoreAllMocks(); - }); - afterAll(() => server.close()); - - it("lists every workspace at the root and opens a selected card without losing env", async () => { - let registryRequests = 0; - let alphaOverviewRequests = 0; - const response: WorkspacesResponse = { - schema: "one-cli/workspaces/v1", - currentEntryId: beta.entryId, - workspaces: [alpha, beta], - }; - server.use( - http.get("http://localhost/api/workspaces", () => { - registryRequests += 1; - return HttpResponse.json(response); - }), - http.get("http://localhost/api/workspaces/alpha-entry/overview", () => { - alphaOverviewRequests += 1; - return HttpResponse.json(workspaceOverview(alpha)); - }), - ); - registerCatalogHandler(); - const user = userEvent.setup(); - - renderDashboard("/?env=prod"); - - const content = within(screen.getByTestId("route-content")); - expect(await content.findByRole("heading", { name: "Workspaces" })).toBeDefined(); - expect(content.getByRole("link", { name: /Alpha/ })).toBeDefined(); - expect(content.getByRole("link", { name: /Beta/ })).toBeDefined(); - expect(screen.getByTestId("location").textContent).toBe("/"); - expect(screen.getByTestId("location-search").textContent).toBe("?env=prod"); - expect(alphaOverviewRequests).toBe(0); - - await user.click(content.getByRole("link", { name: /Alpha/ })); - - expect(await content.findByRole("region", { name: "Project settings" })).toBeDefined(); - expect(content.getByRole("button", { name: "alpha-web apps/web" })).toBeDefined(); - await waitFor(() => - expect(screen.getByTestId("location").textContent).toBe("/workspace/alpha-entry"), - ); - expect(screen.getByTestId("location-search").textContent).toBe("?env=prod"); - expect(registryRequests).toBe(1); - expect(alphaOverviewRequests).toBe(1); - }); - - it("keeps an empty registry on the Workspace home instead of showing Settings", async () => { - server.use( - http.get("http://localhost/api/workspaces", () => - HttpResponse.json({ - schema: "one-cli/workspaces/v1", - workspaces: [], - } satisfies WorkspacesResponse), - ), - ); - - renderDashboard("/?env=staging"); - - const content = within(screen.getByTestId("route-content")); - expect(await content.findByRole("heading", { name: "Workspaces" })).toBeDefined(); - expect(content.getByRole("heading", { name: "No Workspaces yet" })).toBeDefined(); - expect(content.queryByRole("heading", { name: "Infisical" })).toBeNull(); - expect(screen.getByTestId("location").textContent).toBe("/"); - expect(screen.getByTestId("location-search").textContent).toBe("?env=staging"); - }); - - it("switches the scoped overview and forgets only an unavailable registry entry", async () => { - let deletedEntry = ""; - server.use( - http.get("http://localhost/api/workspaces", () => - HttpResponse.json({ - schema: "one-cli/workspaces/v1", - currentEntryId: alpha.entryId, - workspaces: [alpha, beta, broken].filter( - (workspace) => workspace.entryId !== deletedEntry, - ), - } satisfies WorkspacesResponse), - ), - http.get("http://localhost/api/workspaces/alpha-entry/overview", () => - HttpResponse.json(workspaceOverview(alpha)), - ), - http.get("http://localhost/api/workspaces/beta-entry/overview", () => - HttpResponse.json(workspaceOverview(beta)), - ), - http.delete("http://localhost/api/workspaces/:entryId", ({ params }) => { - deletedEntry = String(params.entryId); - return new HttpResponse(null, { status: 204 }); - }), - ); - registerCatalogHandler(); - const user = userEvent.setup(); - - renderDashboard("/workspace/alpha-entry"); - expect(await screen.findByRole("region", { name: "Project settings" })).toBeDefined(); - - await user.click(screen.getByRole("link", { name: "Home" })); - await user.click(await screen.findByRole("link", { name: /Beta/ })); - await waitFor(() => - expect(screen.getByTestId("location").textContent).toBe("/workspace/beta-entry"), - ); - expect(await screen.findByRole("button", { name: "beta-web apps/web" })).toBeDefined(); - - await user.click(screen.getByRole("link", { name: "Home" })); - await user.click(await screen.findByRole("button", { name: "Remove Broken" })); - const confirmation = await screen.findByRole("alertdialog"); - expect( - within(confirmation).getByText( - 'Remove Workspace "Broken"? This only removes the local registry entry; no project files or remote variables will be deleted.', - ), - ).toBeDefined(); - await user.click(within(confirmation).getByRole("button", { name: "Remove Broken" })); - await waitFor(() => expect(deletedEntry).toBe("broken-entry")); - expect(screen.queryByRole("link", { name: /Broken/ })).toBeNull(); - }); - - it("keeps duplicated workspace identities visible but read-only", async () => { - const duplicate = { ...alpha, status: "identity-conflict" as const }; - server.use( - http.get("http://localhost/api/workspaces", () => - HttpResponse.json({ - schema: "one-cli/workspaces/v1", - currentEntryId: duplicate.entryId, - workspaces: [duplicate], - } satisfies WorkspacesResponse), - ), - http.get("http://localhost/api/workspaces/alpha-entry/overview", () => - HttpResponse.json(workspaceOverview(duplicate)), - ), - ); - registerCatalogHandler(); - const user = userEvent.setup(); - - renderDashboard("/workspace/alpha-entry"); - - expect( - await screen.findByText("Read-only: this Workspace ID is used by another path"), - ).toBeDefined(); - const inspector = screen.getByRole("region", { name: "Project settings" }); - await user.click(within(inspector).getByRole("button", { name: "Workspace settings" })); - const dialog = await screen.findByRole("dialog", { name: "Workspace settings" }); - expect( - ((await within(dialog).findByRole("button", { name: "Bind Infisical" })) as HTMLButtonElement) - .disabled, - ).toBe(true); - }); - - it("opens single-account settings", async () => { - registerSettingsHandlers(); - - renderDashboard("/settings"); - - expect(await screen.findByRole("heading", { name: "Infisical" })).toBeDefined(); - expect(screen.getByTestId("location").textContent).toBe("/settings"); - expect(screen.queryByText("env/infisical")).toBeNull(); - }); - - it("redirects the legacy Profile route to Settings", async () => { - registerSettingsHandlers(); - - renderDashboard("/profile?env=prod"); - - await waitFor(() => expect(screen.getByTestId("location").textContent).toBe("/settings")); - expect(screen.getByTestId("location-search").textContent).toBe("?env=prod"); - expect(await screen.findByRole("heading", { name: "Infisical" })).toBeDefined(); - }); - - it("redirects legacy section URLs to the corresponding Settings backend", async () => { - registerSettingsHandlers(); - - renderDashboard("/section/env/infisical?env=staging"); - - await waitFor(() => expect(screen.getByTestId("location").textContent).toBe("/settings")); - expect(screen.getByTestId("location-search").textContent).toBe("?env=staging"); - expect(await screen.findByRole("heading", { name: "Infisical" })).toBeDefined(); - }); - - it("exposes shared credentials and account settings through the actual application navigation", async () => { - await i18n.changeLanguage("en-US"); - server.use( - http.get("http://localhost/api/session", () => - HttpResponse.json({ session: { loggedIn: false, expired: false } }), - ), - http.get("http://localhost/api/global-env/location", () => - HttpResponse.json({ location: null }), - ), - http.get("http://localhost/api/workspaces", () => - HttpResponse.json({ schema: "one-cli/workspaces/v1", workspaces: [] }), - ), - ); - const user = userEvent.setup(); - render( - new Map(), shouldRetryOnError: false }}> - - - - , - ); - await user.click(screen.getByRole("button", { name: "One CLI navigation menu" })); - await user.click(await screen.findByRole("menuitem", { name: "Shared credentials" })); - await screen.findByRole("heading", { name: "Shared credentials" }); - await user.click(screen.getByRole("link", { name: "Sign in with browser" })); - await screen.findByRole("heading", { name: "Infisical" }); - expect(screen.getByRole("button", { name: "Sign in with browser" })).toBeTruthy(); - }); -}); diff --git a/apps/dashboard/src/test/setup.ts b/apps/dashboard/src/test/setup.ts deleted file mode 100644 index 32e6b7ca..00000000 --- a/apps/dashboard/src/test/setup.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { cleanup } from "@testing-library/react"; -import { afterEach } from "vitest"; - -afterEach(() => cleanup()); - -class ResizeObserverStub { - disconnect() {} - observe() {} - unobserve() {} -} - -Object.defineProperty(globalThis, "ResizeObserver", { - configurable: true, - writable: true, - value: ResizeObserverStub, -}); - -Object.defineProperty(window, "matchMedia", { - configurable: true, - writable: true, - value: (query: string) => ({ - matches: false, - media: query, - onchange: null, - addEventListener() {}, - removeEventListener() {}, - addListener() {}, - removeListener() {}, - dispatchEvent: () => false, - }), -}); - -HTMLElement.prototype.scrollIntoView = () => {}; -HTMLElement.prototype.hasPointerCapture = () => false; -HTMLElement.prototype.releasePointerCapture = () => {}; diff --git a/apps/dashboard/vitest.config.ts b/apps/dashboard/vitest.config.ts deleted file mode 100644 index d90914a8..00000000 --- a/apps/dashboard/vitest.config.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { defineConfig, mergeConfig } from "vitest/config"; -import viteConfig from "./vite.config.ts"; - -const nodeMajor = Number.parseInt(process.versions.node, 10); - -export default mergeConfig( - viteConfig, - defineConfig({ - test: { - // Node 25 exposes native Web Storage before Vitest installs jsdom. - // Disable only the worker's Node implementation so tests use jsdom's - // isolated localStorage without requiring a persistent storage file. - execArgv: nodeMajor >= 25 ? ["--no-experimental-webstorage"] : [], - environment: "jsdom", - environmentOptions: { - jsdom: { url: "http://localhost/" }, - }, - setupFiles: ["./src/test/setup.ts"], - }, - }), -); diff --git a/apps/docs/content/docs/en/skills.md b/apps/docs/content/docs/en/skills.md index 47bb327d..5e3d2177 100644 --- a/apps/docs/content/docs/en/skills.md +++ b/apps/docs/content/docs/en/skills.md @@ -37,8 +37,8 @@ Every template gets the common skills `one-cli` and `find-skills`. Node template | `fumadocs-docs` | The Next/React skills above plus `one-fumadocs` | | `react-spa` | `one-web`, React best practices, composition patterns, `shadcn`, `vite` | | `expo-mobile` | `one-expo`, `expo-overview`, `expo-router`, `expo-data-fetching`, `expo-dev-client`, `expo-upgrade`, `expo-project-structure` | -| `ts-library` | `tsdown`, `vitest` | -| `electron-app` | `one-web`, React best practices, composition patterns, `shadcn`, `vite`, `vitest`, `tsdown`, `one-electron` | +| `ts-library` | `tsdown` | +| `electron-app` | `one-web`, React best practices, composition patterns, `shadcn`, `vite`, `tsdown`, `one-electron` | | `empty-app`, `empty-service`, `empty-library` | Common skills only | Empty templates do not prescribe a language, framework, UI, database, or internal architecture; the user chooses the stack. Common skills guide the One workspace layout and workflow without requiring another template's stack. Once the stack is chosen, install matching development skills as needed with `one skills add`. The generated `AGENTS.md` follows the same rule: complex scripts use the project's chosen language without requiring Node or TypeScript. diff --git a/apps/docs/content/docs/en/templates.md b/apps/docs/content/docs/en/templates.md index fb362cd1..f80d6bac 100644 --- a/apps/docs/content/docs/en/templates.md +++ b/apps/docs/content/docs/en/templates.md @@ -126,7 +126,7 @@ Directories preserve the supplied project name; npm names use kebab-case. All th join the root pnpm workspace and share its lockfile, without a nested workspace. The manifest records `[groups.desktop]` with these three members. Use -`one run desktop:dev`, `one run desktop:build`, `one run desktop:test`, and +`one run desktop:dev`, `one run desktop:build`, and `one run desktop:pack` to operate on the group. These aliases survive `one init mise`. Each member remains independently addressable, such as `one build -p desktop-main`. The main process uses Awilix function factories; the renderer uses React, Vite, @@ -163,7 +163,7 @@ For example, inside the One CLI source repository: ```sh cd packages/templates/go-api -go test ./... +task check go run ./cmd/server ``` @@ -224,12 +224,4 @@ After editing, run these commands from the repository root: ```sh mise run sync-bundled mise run check -mise run check:templates ``` - -`check:templates` installs the template dependencies, builds the Go and -Electron sources, checks generated formatting for every Node template, and -builds two differently named Electron projects, two Go projects, and both new -Next.js static sites in a temporary workspace. It checks bilingual pages and -static documentation search. It requires network access and the corresponding -toolchains, and does not change the developer's global language preference. diff --git a/apps/docs/content/docs/zh/skills.md b/apps/docs/content/docs/zh/skills.md index 6215f225..23922839 100644 --- a/apps/docs/content/docs/zh/skills.md +++ b/apps/docs/content/docs/zh/skills.md @@ -37,8 +37,8 @@ one add react-spa --name web --skip-skills | `fumadocs-docs` | 上述 Next/React skills,以及 `one-fumadocs` | | `react-spa` | `one-web`、React 最佳实践、组合模式、`shadcn`、`vite` | | `expo-mobile` | `one-expo`、`expo-overview`、`expo-router`、`expo-data-fetching`、`expo-dev-client`、`expo-upgrade`、`expo-project-structure` | -| `ts-library` | `tsdown`、`vitest` | -| `electron-app` | `one-web`、React 最佳实践、组合模式、`shadcn`、`vite`、`vitest`、`tsdown`、`one-electron` | +| `ts-library` | `tsdown` | +| `electron-app` | `one-web`、React 最佳实践、组合模式、`shadcn`、`vite`、`tsdown`、`one-electron` | | `empty-app`、`empty-service`、`empty-library` | 仅基础 skills | 空模板不预设语言、框架、UI、数据库或项目内部架构,技术栈由用户自由选择。基础 skills 只指导 One 工作区目录与操作流程,不要求采用其他模板的技术栈。确定技术栈后,再按需通过 `one skills add` 安装匹配的开发 skills。默认生成的 `AGENTS.md` 也遵循这一规则,复杂脚本使用项目选择的语言,不强制引入 Node 或 TypeScript。 diff --git a/apps/docs/content/docs/zh/templates.md b/apps/docs/content/docs/zh/templates.md index 7d0a9242..f384291b 100644 --- a/apps/docs/content/docs/zh/templates.md +++ b/apps/docs/content/docs/zh/templates.md @@ -123,7 +123,7 @@ npm 包名分别为 `desktop-renderer`、`desktop-main` 和 `desktop-preload`。 pnpm workspace 和 lockfile,不再创建嵌套工作区。 清单通过 `[groups.desktop]` 记录三个成员。使用 `one run desktop:dev`、 -`one run desktop:build`、`one run desktop:test` 和 `one run desktop:pack` +`one run desktop:build` 和 `one run desktop:pack` 操作整组项目;重新运行 `one init mise` 后这些任务仍会保留。 每个成员仍可独立操作,例如 `one build -p desktop-main`。 主进程使用 Awilix 函数工厂,renderer 使用 React、Vite 和基于 Base UI 的 shadcn/ui。 @@ -155,7 +155,7 @@ One 工作区根目录管理。 ```sh cd packages/templates/go-api -go test ./... +task check go run ./cmd/server ``` @@ -211,10 +211,4 @@ Go 模板只维护一份正常的 `go.mod` 和必要的 `go.sum`。资源打包 ```sh mise run sync-bundled mise run check -mise run check:templates ``` - -`check:templates` 会安装模板依赖,构建 Go/Electron 源码与生成的 Next.js 静态站点,检查全部 -Node 模板的生成格式,并在临时工作区构建两个不同名称的 Electron 项目、两个 Go 项目与两种 -Next.js 静态站点,同时验证中英文页面和文档静态搜索。 -该检查需要网络及对应工具链;它不会修改开发者的全局语言偏好。 diff --git a/apps/docs/vercel.json b/apps/docs/vercel.json deleted file mode 100644 index fca9d8db..00000000 --- a/apps/docs/vercel.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "$schema": "https://openapi.vercel.sh/vercel.json", - "framework": "nextjs", - "installCommand": "pnpm install --frozen-lockfile", - "buildCommand": "pnpm build", - "redirects": [ - { - "source": "/:path*", - "has": [ - { - "type": "host", - "value": "1cli.dev" - } - ], - "destination": "https://www.1cli.dev/:path*", - "permanent": true - } - ], - "headers": [ - { - "source": "/install.sh", - "headers": [ - { - "key": "Cache-Control", - "value": "public, max-age=300, must-revalidate" - }, - { - "key": "Content-Type", - "value": "text/plain; charset=utf-8" - } - ] - } - ] -} diff --git a/mise.toml b/mise.toml index 9f7c026e..530ff86d 100644 --- a/mise.toml +++ b/mise.toml @@ -76,43 +76,9 @@ run = "pnpm run check" description = "Check documentation, Go, and Dashboard" depends = ["verify-node-workspace", "verify-docs", "check:go", "check:dashboard"] -[tasks."test:go:plain"] -depends = ["kernel:test", "cli:test"] - -[tasks."test:go"] -depends = ["kernel:test:race", "cli:test:race"] - -[tasks."test:dashboard"] -depends = ["build-web"] -dir = "apps/dashboard" -run = "pnpm run test" - -[tasks."test:snapshot"] -depends = ["cli:test:snapshot"] - -[tasks."check:test"] -depends = ["test:go:plain", "test:dashboard"] - [tasks.check] -description = "Run static checks and tests on the current platform" -depends = ["check:static", "check:test"] - -[tasks."check:templates"] -description = "Build all generated starters and verify health, IPC, native listeners, and static search" -depends = ["sync-bundled", "sync-web"] -run = "go test -count=1 -timeout=20m -v ./packages/cli/internal/modules/creation -run ^TestRunnableTemplateSourcesAndProjects$" -env = { ONE_TEST_TEMPLATE_BUILDS = "1" } - -[tasks.ci] -depends = ["check"] - -[tasks.test] -description = "Run Go race and Dashboard tests" -depends = ["test:go", "test:dashboard"] - -[tasks.pre-push] -description = "Run the local PR gate, Go race tests, and template builds" -depends = ["check", "test:go", "check:templates"] +description = "Run static checks on the current platform" +depends = ["check:static"] [tasks.fmt] depends = ["kernel:fmt", "cli:fmt"] @@ -127,13 +93,10 @@ depends = ["check:go"] depends = ["kernel:tidy", "cli:tidy"] run = "go work sync" -[tasks."hooks:install"] -run = "git config --local core.hooksPath .githooks" - [tasks.install] -description = "Build a local One launcher and enable Git hooks" +description = "Build and install a local One launcher" depends = ["build-local"] -run = ["git config --local core.hooksPath .githooks", "task -d packages/cli install-launcher"] +run = "task -d packages/cli install-launcher" # Compatibility task names use the same project entries. # 兼容旧入口,统一复用项目任务。 @@ -217,27 +180,6 @@ dir = 'packages/cli' raw_args = true run = 'task gen-error-codes --' -[tasks."cli:test"] -depends = ["//:build"] - -dir = 'packages/cli' -raw_args = true -run = 'task test --' - -[tasks."cli:test:race"] -depends = ["//:build"] - -dir = 'packages/cli' -raw_args = true -run = 'task test:race --' - -[tasks."cli:test:snapshot"] -depends = ["//:build"] - -dir = 'packages/cli' -raw_args = true -run = 'task test:snapshot --' - [tasks."cli:sync-web"] depends = ["//:build-web"] @@ -321,16 +263,6 @@ dir = 'apps/dashboard' raw_args = true run = 'pnpm run preview' -[tasks.'dashboard:test'] -dir = 'apps/dashboard' -raw_args = true -run = 'pnpm run test' - -[tasks.'dashboard:test:watch'] -dir = 'apps/dashboard' -raw_args = true -run = 'pnpm run test:watch' - [tasks.'docs:postinstall'] dir = 'apps/docs' raw_args = true @@ -356,19 +288,11 @@ dir = 'packages/kernel' raw_args = true run = 'task fmt --' -[tasks.'kernel:test'] -dir = 'packages/kernel' -raw_args = true -run = 'task test --' - -[tasks.'kernel:test:race'] -dir = 'packages/kernel' -raw_args = true -run = 'task test:race --' - [tasks.'kernel:tidy'] dir = 'packages/kernel' raw_args = true run = 'task tidy --' -# one:managed-v1 {"[\"monorepo\",\"config_roots\"]":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","[\"monorepo\",\"lockfile\"]":"fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa","[\"monorepo_root\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:build\",\"description\"]":"cde82f02859fcc6f4226e9e2b4b98fcd1bb53d68fd66378d6b12ed9587dd9041","[\"tasks\",\"cli:build\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:build\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:build\",\"run\"]":"18131ed52d0a32e786dbd35201898a7653480b88b83411cc7fee0a5ec85e59aa","[\"tasks\",\"cli:build-all\",\"description\"]":"5a2810b1f882281debfbcb8fc7ad8840986e6b7c9d2332a5a77f3ad104387f51","[\"tasks\",\"cli:build-all\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:build-all\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:build-all\",\"run\"]":"d78e59e35ec8d8decf668fcc2a4455247d6ac4426dd5c3df64cb78b977b6e931","[\"tasks\",\"cli:build-local\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:build-local\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:build-local\",\"run\"]":"21684c8d2565f745dbc3dac57038308643e7db2fc726f55bf64418a97e19b110","[\"tasks\",\"cli:check\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:check\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:check\",\"run\"]":"6e132a2fffbaf1847c695d5c05c675359fc1784f03648cc7b379edd34cf597e1","[\"tasks\",\"cli:dev\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:dev\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:dev\",\"run\"]":"6dc36fa9540069369ce47f562fb8129dd298b89e080964f0fe4a3caa9093b1dc","[\"tasks\",\"cli:fmt\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:fmt\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:fmt\",\"run\"]":"acb9678093d9f4348d30353b881c2a4e431b9f3899d9b9576c2929340d692caf","[\"tasks\",\"cli:gen-error-codes\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:gen-error-codes\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:gen-error-codes\",\"run\"]":"29a53267fab07dbc9e8c073c51e0cdc1e42a0995eea96fd3d619559d41fb6a96","[\"tasks\",\"cli:install-launcher\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:install-launcher\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:install-launcher\",\"run\"]":"ce26f4853dc6ef2fb03fa221a9d5c48c2c3f981ceb3d9c37026a34edc0dad898","[\"tasks\",\"cli:sync-bundled\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:sync-bundled\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:sync-bundled\",\"run\"]":"7ececd4bafb910101d2a3cb6df7b0636d11934aea810f0a079a8af2412633227","[\"tasks\",\"cli:sync-web\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:sync-web\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:sync-web\",\"run\"]":"23f729b7c6d29db42a9822012ad9de28b74ed36c58190041a98ff06ec9bb962e","[\"tasks\",\"cli:test\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:test\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:test\",\"run\"]":"f5f157e1853712703e265788f7e0c7ad096c57f7990aecb8f2d4b8321c831731","[\"tasks\",\"cli:test:race\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:test:race\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:test:race\",\"run\"]":"4c1e4a0e73fd97d89181d24b86c1e755269026bb789d744e9b5977cb10d3384a","[\"tasks\",\"cli:test:snapshot\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:test:snapshot\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:test:snapshot\",\"run\"]":"2ecc788730158a356e22d755ca30950547792403237d821dc2fe2da9821bb476","[\"tasks\",\"cli:tidy\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:tidy\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:tidy\",\"run\"]":"6c28cbe6d68ef31afaaec32610c105a7bc0241ea3f201723cb3e878d1e938a58","[\"tasks\",\"cli:verify-docs\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:verify-docs\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:verify-docs\",\"run\"]":"1229bc1f91610743995ac6f52b17007e2506a85d4a0c28daa14845807a196ad2","[\"tasks\",\"cli:verify-node-workspace\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:verify-node-workspace\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:verify-node-workspace\",\"run\"]":"f8e687c8818a33f3dbc562a049f85c7cb13c57906729c267d2d6ef7f0961421c","[\"tasks\",\"dashboard:build\",\"dir\"]":"22dfd6d18d0603a9f926e2c80bae6c8e4d140530c91be8a6afacedd68fe35a6b","[\"tasks\",\"dashboard:build\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"dashboard:build\",\"run\"]":"44a8f3ee49af693fc75b0602e60d514f939e9a7c03807f8009a8da4bf72c55b7","[\"tasks\",\"dashboard:check\",\"dir\"]":"22dfd6d18d0603a9f926e2c80bae6c8e4d140530c91be8a6afacedd68fe35a6b","[\"tasks\",\"dashboard:check\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"dashboard:check\",\"run\"]":"480d44525792cdcfb13bc036ea04c0d69860cadc1ac61cd7d82acf9fbe75e087","[\"tasks\",\"dashboard:check:fix\",\"dir\"]":"22dfd6d18d0603a9f926e2c80bae6c8e4d140530c91be8a6afacedd68fe35a6b","[\"tasks\",\"dashboard:check:fix\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"dashboard:check:fix\",\"run\"]":"07a7e4c2a92d3ff17ac4cb0a027d0b90c9c2df42643de06ffff66fc1dfc6e175","[\"tasks\",\"dashboard:dev\",\"dir\"]":"22dfd6d18d0603a9f926e2c80bae6c8e4d140530c91be8a6afacedd68fe35a6b","[\"tasks\",\"dashboard:dev\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"dashboard:dev\",\"run\"]":"09fae686663623cc05ddba7b868aded9993ec812b0fe57a9a8df97bd4869b906","[\"tasks\",\"dashboard:format\",\"dir\"]":"22dfd6d18d0603a9f926e2c80bae6c8e4d140530c91be8a6afacedd68fe35a6b","[\"tasks\",\"dashboard:format\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"dashboard:format\",\"run\"]":"8e903c4de3b9fae6a2b8155376ba843743893738f807f09779dfbd685d1b2b65","[\"tasks\",\"dashboard:format:fix\",\"dir\"]":"22dfd6d18d0603a9f926e2c80bae6c8e4d140530c91be8a6afacedd68fe35a6b","[\"tasks\",\"dashboard:format:fix\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"dashboard:format:fix\",\"run\"]":"701c6c4e415cf6ac62e176a0f2b6fd3e5457ce7f11abce9e900902dadef129c5","[\"tasks\",\"dashboard:lint\",\"dir\"]":"22dfd6d18d0603a9f926e2c80bae6c8e4d140530c91be8a6afacedd68fe35a6b","[\"tasks\",\"dashboard:lint\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"dashboard:lint\",\"run\"]":"300487aff7ad5c9e32e3f0861154e2cb85d33e46a8c1cb8ea91a4258a1eca83b","[\"tasks\",\"dashboard:lint:fix\",\"dir\"]":"22dfd6d18d0603a9f926e2c80bae6c8e4d140530c91be8a6afacedd68fe35a6b","[\"tasks\",\"dashboard:lint:fix\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"dashboard:lint:fix\",\"run\"]":"0e8e5238b5894c168eda63e82ca2cc3e78650f03cab84fc6a8099932efb2fe79","[\"tasks\",\"dashboard:preview\",\"dir\"]":"22dfd6d18d0603a9f926e2c80bae6c8e4d140530c91be8a6afacedd68fe35a6b","[\"tasks\",\"dashboard:preview\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"dashboard:preview\",\"run\"]":"f08ab7c03f384e09bfb088007fc8f6f88256397166afdee8fae035f81fe85caf","[\"tasks\",\"dashboard:test\",\"dir\"]":"22dfd6d18d0603a9f926e2c80bae6c8e4d140530c91be8a6afacedd68fe35a6b","[\"tasks\",\"dashboard:test\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"dashboard:test\",\"run\"]":"878702481dcee3cc3b7a71f64b1b5e7b87029a63b40dae01a103d91569b41dfa","[\"tasks\",\"dashboard:test:watch\",\"dir\"]":"22dfd6d18d0603a9f926e2c80bae6c8e4d140530c91be8a6afacedd68fe35a6b","[\"tasks\",\"dashboard:test:watch\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"dashboard:test:watch\",\"run\"]":"d977bc939aaf0ee4be48efb47e0a1092b71853e431206f97050993d9cb2cdee7","[\"tasks\",\"docs:postinstall\",\"dir\"]":"2e3e8b4189b911510e95ceea3224aff2883d9b522446768f70131a68cd15f6b0","[\"tasks\",\"docs:postinstall\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"docs:postinstall\",\"run\"]":"be5fe2b18429f635a0fb7c3639f658edf4e4c8c1dc524c1f07cdad6f10cfa299","[\"tasks\",\"docs:start\",\"dir\"]":"2e3e8b4189b911510e95ceea3224aff2883d9b522446768f70131a68cd15f6b0","[\"tasks\",\"docs:start\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"docs:start\",\"run\"]":"ea9a60458c4d37a2ad05f5bb1dc6bf9f3cea19f5576f9acdc7a2376b535fc823","[\"tasks\",\"kernel:build\",\"dir\"]":"403367a8b01cb77ce010f79e218061dbecc06eea7b0dfb32ba8c96222e8b4b32","[\"tasks\",\"kernel:build\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"kernel:build\",\"run\"]":"18131ed52d0a32e786dbd35201898a7653480b88b83411cc7fee0a5ec85e59aa","[\"tasks\",\"kernel:check\",\"dir\"]":"403367a8b01cb77ce010f79e218061dbecc06eea7b0dfb32ba8c96222e8b4b32","[\"tasks\",\"kernel:check\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"kernel:check\",\"run\"]":"6e132a2fffbaf1847c695d5c05c675359fc1784f03648cc7b379edd34cf597e1","[\"tasks\",\"kernel:fmt\",\"dir\"]":"403367a8b01cb77ce010f79e218061dbecc06eea7b0dfb32ba8c96222e8b4b32","[\"tasks\",\"kernel:fmt\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"kernel:fmt\",\"run\"]":"acb9678093d9f4348d30353b881c2a4e431b9f3899d9b9576c2929340d692caf","[\"tasks\",\"kernel:test\",\"dir\"]":"403367a8b01cb77ce010f79e218061dbecc06eea7b0dfb32ba8c96222e8b4b32","[\"tasks\",\"kernel:test\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"kernel:test\",\"run\"]":"f5f157e1853712703e265788f7e0c7ad096c57f7990aecb8f2d4b8321c831731","[\"tasks\",\"kernel:test:race\",\"dir\"]":"403367a8b01cb77ce010f79e218061dbecc06eea7b0dfb32ba8c96222e8b4b32","[\"tasks\",\"kernel:test:race\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"kernel:test:race\",\"run\"]":"4c1e4a0e73fd97d89181d24b86c1e755269026bb789d744e9b5977cb10d3384a","[\"tasks\",\"kernel:tidy\",\"dir\"]":"403367a8b01cb77ce010f79e218061dbecc06eea7b0dfb32ba8c96222e8b4b32","[\"tasks\",\"kernel:tidy\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"kernel:tidy\",\"run\"]":"6c28cbe6d68ef31afaaec32610c105a7bc0241ea3f201723cb3e878d1e938a58"} + + +# one:managed-v1 {"[\"monorepo\",\"config_roots\"]":"4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945","[\"monorepo\",\"lockfile\"]":"fcbcf165908dd18a9e49f7ff27810176db8e9f63b4352213741664245224f8aa","[\"monorepo_root\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:build\",\"description\"]":"cde82f02859fcc6f4226e9e2b4b98fcd1bb53d68fd66378d6b12ed9587dd9041","[\"tasks\",\"cli:build\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:build\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:build\",\"run\"]":"18131ed52d0a32e786dbd35201898a7653480b88b83411cc7fee0a5ec85e59aa","[\"tasks\",\"cli:build-all\",\"description\"]":"5a2810b1f882281debfbcb8fc7ad8840986e6b7c9d2332a5a77f3ad104387f51","[\"tasks\",\"cli:build-all\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:build-all\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:build-all\",\"run\"]":"d78e59e35ec8d8decf668fcc2a4455247d6ac4426dd5c3df64cb78b977b6e931","[\"tasks\",\"cli:build-local\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:build-local\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:build-local\",\"run\"]":"21684c8d2565f745dbc3dac57038308643e7db2fc726f55bf64418a97e19b110","[\"tasks\",\"cli:check\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:check\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:check\",\"run\"]":"6e132a2fffbaf1847c695d5c05c675359fc1784f03648cc7b379edd34cf597e1","[\"tasks\",\"cli:dev\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:dev\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:dev\",\"run\"]":"6dc36fa9540069369ce47f562fb8129dd298b89e080964f0fe4a3caa9093b1dc","[\"tasks\",\"cli:fmt\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:fmt\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:fmt\",\"run\"]":"acb9678093d9f4348d30353b881c2a4e431b9f3899d9b9576c2929340d692caf","[\"tasks\",\"cli:gen-error-codes\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:gen-error-codes\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:gen-error-codes\",\"run\"]":"29a53267fab07dbc9e8c073c51e0cdc1e42a0995eea96fd3d619559d41fb6a96","[\"tasks\",\"cli:install-launcher\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:install-launcher\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:install-launcher\",\"run\"]":"ce26f4853dc6ef2fb03fa221a9d5c48c2c3f981ceb3d9c37026a34edc0dad898","[\"tasks\",\"cli:sync-bundled\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:sync-bundled\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:sync-bundled\",\"run\"]":"7ececd4bafb910101d2a3cb6df7b0636d11934aea810f0a079a8af2412633227","[\"tasks\",\"cli:sync-web\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:sync-web\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:sync-web\",\"run\"]":"23f729b7c6d29db42a9822012ad9de28b74ed36c58190041a98ff06ec9bb962e","[\"tasks\",\"cli:tidy\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:tidy\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:tidy\",\"run\"]":"6c28cbe6d68ef31afaaec32610c105a7bc0241ea3f201723cb3e878d1e938a58","[\"tasks\",\"cli:verify-docs\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:verify-docs\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:verify-docs\",\"run\"]":"1229bc1f91610743995ac6f52b17007e2506a85d4a0c28daa14845807a196ad2","[\"tasks\",\"cli:verify-node-workspace\",\"dir\"]":"55cd6e0d108d1cf9ac365b98236d1b9efc9c78f896368d129c37e53831404746","[\"tasks\",\"cli:verify-node-workspace\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"cli:verify-node-workspace\",\"run\"]":"f8e687c8818a33f3dbc562a049f85c7cb13c57906729c267d2d6ef7f0961421c","[\"tasks\",\"dashboard:build\",\"dir\"]":"22dfd6d18d0603a9f926e2c80bae6c8e4d140530c91be8a6afacedd68fe35a6b","[\"tasks\",\"dashboard:build\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"dashboard:build\",\"run\"]":"44a8f3ee49af693fc75b0602e60d514f939e9a7c03807f8009a8da4bf72c55b7","[\"tasks\",\"dashboard:check\",\"dir\"]":"22dfd6d18d0603a9f926e2c80bae6c8e4d140530c91be8a6afacedd68fe35a6b","[\"tasks\",\"dashboard:check\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"dashboard:check\",\"run\"]":"480d44525792cdcfb13bc036ea04c0d69860cadc1ac61cd7d82acf9fbe75e087","[\"tasks\",\"dashboard:check:fix\",\"dir\"]":"22dfd6d18d0603a9f926e2c80bae6c8e4d140530c91be8a6afacedd68fe35a6b","[\"tasks\",\"dashboard:check:fix\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"dashboard:check:fix\",\"run\"]":"07a7e4c2a92d3ff17ac4cb0a027d0b90c9c2df42643de06ffff66fc1dfc6e175","[\"tasks\",\"dashboard:dev\",\"dir\"]":"22dfd6d18d0603a9f926e2c80bae6c8e4d140530c91be8a6afacedd68fe35a6b","[\"tasks\",\"dashboard:dev\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"dashboard:dev\",\"run\"]":"09fae686663623cc05ddba7b868aded9993ec812b0fe57a9a8df97bd4869b906","[\"tasks\",\"dashboard:format\",\"dir\"]":"22dfd6d18d0603a9f926e2c80bae6c8e4d140530c91be8a6afacedd68fe35a6b","[\"tasks\",\"dashboard:format\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"dashboard:format\",\"run\"]":"8e903c4de3b9fae6a2b8155376ba843743893738f807f09779dfbd685d1b2b65","[\"tasks\",\"dashboard:format:fix\",\"dir\"]":"22dfd6d18d0603a9f926e2c80bae6c8e4d140530c91be8a6afacedd68fe35a6b","[\"tasks\",\"dashboard:format:fix\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"dashboard:format:fix\",\"run\"]":"701c6c4e415cf6ac62e176a0f2b6fd3e5457ce7f11abce9e900902dadef129c5","[\"tasks\",\"dashboard:lint\",\"dir\"]":"22dfd6d18d0603a9f926e2c80bae6c8e4d140530c91be8a6afacedd68fe35a6b","[\"tasks\",\"dashboard:lint\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"dashboard:lint\",\"run\"]":"300487aff7ad5c9e32e3f0861154e2cb85d33e46a8c1cb8ea91a4258a1eca83b","[\"tasks\",\"dashboard:lint:fix\",\"dir\"]":"22dfd6d18d0603a9f926e2c80bae6c8e4d140530c91be8a6afacedd68fe35a6b","[\"tasks\",\"dashboard:lint:fix\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"dashboard:lint:fix\",\"run\"]":"0e8e5238b5894c168eda63e82ca2cc3e78650f03cab84fc6a8099932efb2fe79","[\"tasks\",\"dashboard:preview\",\"dir\"]":"22dfd6d18d0603a9f926e2c80bae6c8e4d140530c91be8a6afacedd68fe35a6b","[\"tasks\",\"dashboard:preview\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"dashboard:preview\",\"run\"]":"f08ab7c03f384e09bfb088007fc8f6f88256397166afdee8fae035f81fe85caf","[\"tasks\",\"docs:postinstall\",\"dir\"]":"2e3e8b4189b911510e95ceea3224aff2883d9b522446768f70131a68cd15f6b0","[\"tasks\",\"docs:postinstall\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"docs:postinstall\",\"run\"]":"be5fe2b18429f635a0fb7c3639f658edf4e4c8c1dc524c1f07cdad6f10cfa299","[\"tasks\",\"docs:start\",\"dir\"]":"2e3e8b4189b911510e95ceea3224aff2883d9b522446768f70131a68cd15f6b0","[\"tasks\",\"docs:start\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"docs:start\",\"run\"]":"ea9a60458c4d37a2ad05f5bb1dc6bf9f3cea19f5576f9acdc7a2376b535fc823","[\"tasks\",\"kernel:build\",\"dir\"]":"403367a8b01cb77ce010f79e218061dbecc06eea7b0dfb32ba8c96222e8b4b32","[\"tasks\",\"kernel:build\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"kernel:build\",\"run\"]":"18131ed52d0a32e786dbd35201898a7653480b88b83411cc7fee0a5ec85e59aa","[\"tasks\",\"kernel:check\",\"dir\"]":"403367a8b01cb77ce010f79e218061dbecc06eea7b0dfb32ba8c96222e8b4b32","[\"tasks\",\"kernel:check\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"kernel:check\",\"run\"]":"6e132a2fffbaf1847c695d5c05c675359fc1784f03648cc7b379edd34cf597e1","[\"tasks\",\"kernel:fmt\",\"dir\"]":"403367a8b01cb77ce010f79e218061dbecc06eea7b0dfb32ba8c96222e8b4b32","[\"tasks\",\"kernel:fmt\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"kernel:fmt\",\"run\"]":"acb9678093d9f4348d30353b881c2a4e431b9f3899d9b9576c2929340d692caf","[\"tasks\",\"kernel:tidy\",\"dir\"]":"403367a8b01cb77ce010f79e218061dbecc06eea7b0dfb32ba8c96222e8b4b32","[\"tasks\",\"kernel:tidy\",\"raw_args\"]":"b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b","[\"tasks\",\"kernel:tidy\",\"run\"]":"6c28cbe6d68ef31afaaec32610c105a7bc0241ea3f201723cb3e878d1e938a58"} diff --git a/packages/cli/Taskfile.yml b/packages/cli/Taskfile.yml index d16d1d4d..89ed6614 100644 --- a/packages/cli/Taskfile.yml +++ b/packages/cli/Taskfile.yml @@ -31,12 +31,6 @@ tasks: out=../../go-dist/one-${os}-${arch} [ "$os" = "windows" ] && out="${out}.exe" GOOS=$os GOARCH=$arch go build -trimpath -ldflags '-s -w -X main.version={{.BUILD_VERSION}}' -o "$out" ./cmd/one - test: - cmds: ['go test ./... {{.CLI_ARGS}}'] - test:race: - cmds: ['go test -race ./... {{.CLI_ARGS}}'] - test:snapshot: - cmds: ['go test -run Snapshot ./... {{.CLI_ARGS}}'] check: cmds: - go vet ./... diff --git a/packages/cli/go.mod b/packages/cli/go.mod index 6ffb4b94..715e6908 100644 --- a/packages/cli/go.mod +++ b/packages/cli/go.mod @@ -8,7 +8,6 @@ require ( charm.land/huh/v2 v2.0.3 charm.land/lipgloss/v2 v2.0.6 github.com/charmbracelet/x/ansi v0.11.8 - github.com/creack/pty v1.1.24 github.com/go-resty/resty/v2 v2.17.2 github.com/gofrs/flock v0.13.1 github.com/infisical/go-sdk v0.8.0 diff --git a/packages/cli/internal/adapters/env/infisical/client_error_test.go b/packages/cli/internal/adapters/env/infisical/client_error_test.go deleted file mode 100644 index e934f486..00000000 --- a/packages/cli/internal/adapters/env/infisical/client_error_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package infisical - -import ( - "errors" - "fmt" - "testing" - - sdk "github.com/infisical/go-sdk" - sdkerrors "github.com/infisical/go-sdk/packages/errors" -) - -func TestAPIErrorStatusDoesNotReadPortOrRequestID(t *testing.T) { - for _, tc := range []struct { - name string - err error - code string - }{ - {"unauthorized", &sdk.APIError{StatusCode: 401, URL: "http://localhost:50001"}, "INFISICAL_AUTH_FAILED"}, - {"wrapped unauthorized", fmt.Errorf("list: %w", &sdk.APIError{StatusCode: 401}), "INFISICAL_AUTH_FAILED"}, - {"server error with auth-looking request ID", &sdk.APIError{StatusCode: 500, URL: "http://localhost:40123", ReqId: "unauthorized-401"}, "INFISICAL_API_ERROR"}, - {"closed connection on auth-looking port", sdkerrors.NewRequestError("ListSecrets", errors.New("Get http://127.0.0.1:40123/api/v3/secrets/raw: EOF")), "INFISICAL_API_ERROR"}, - } { - t.Run(tc.name, func(t *testing.T) { - var coded interface{ ErrorCode() string } - err := mapAPIError(tc.err) - if !errors.As(err, &coded) || coded.ErrorCode() != tc.code { - t.Fatalf("code=%v want=%s", err, tc.code) - } - }) - } -} diff --git a/packages/cli/internal/adapters/env/infisical/client_projects_test.go b/packages/cli/internal/adapters/env/infisical/client_projects_test.go deleted file mode 100644 index 65872a26..00000000 --- a/packages/cli/internal/adapters/env/infisical/client_projects_test.go +++ /dev/null @@ -1,160 +0,0 @@ -package infisical - -import ( - "errors" - "net/http" - "net/http/httptest" - "strings" - "testing" - - cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" -) - -// newTestClient builds a Client wired against an httptest server, bypassing -// the Infisical SDK entirely. accessToken is read from the field directly -// so we don't need to construct (or mock) a real SDK instance. -func newTestClient(t *testing.T, baseURL, token string) *Client { - t.Helper() - cfg := &WorkspaceConfig{ProjectID: "ignored", SiteURL: baseURL} - return &Client{ - cfg: cfg, - accessToken: token, - } -} - -func TestCreateProject_Success(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v2/workspace" { - t.Errorf("unexpected path: %s", r.URL.Path) - } - if got := r.Header.Get("Authorization"); got != "Bearer tok" { - t.Errorf("missing/incorrect Authorization header: %q", got) - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"project":{"id":"p_abc","name":"demo"}}`)) - })) - defer srv.Close() - - c := newTestClient(t, srv.URL, "tok") - id, name, err := c.CreateProject("demo") - if err != nil { - t.Fatalf("CreateProject = %v", err) - } - if id != "p_abc" || name != "demo" { - t.Errorf("got id=%q name=%q; want p_abc/demo", id, name) - } -} - -func TestCreateProject_NameTaken_409(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusConflict) - _, _ = w.Write([]byte(`{"message":"project name already exists"}`)) - })) - defer srv.Close() - - c := newTestClient(t, srv.URL, "tok") - _, _, err := c.CreateProject("demo") - if err == nil { - t.Fatalf("expected error; got nil") - } - var typed *output.Error - if !errors.As(err, &typed) || typed.Code != string(cliErrors.INFISICAL_PROJECT_NAME_TAKEN) { - t.Errorf("expected INFISICAL_PROJECT_NAME_TAKEN; got %v", err) - } -} - -func TestCreateProject_NameTaken_400Body(t *testing.T) { - // Some Infisical versions surface name collisions as 400 with a body - // containing "already exists". We branch on body text in that case. - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message":"a project with this name already exists"}`)) - })) - defer srv.Close() - - c := newTestClient(t, srv.URL, "tok") - _, _, err := c.CreateProject("demo") - var typed *output.Error - if !errors.As(err, &typed) || typed.Code != string(cliErrors.INFISICAL_PROJECT_NAME_TAKEN) { - t.Errorf("expected INFISICAL_PROJECT_NAME_TAKEN from 400+body; got %v", err) - } -} - -func TestCreateProject_Forbidden_403(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusForbidden) - _, _ = w.Write([]byte(`{"message":"insufficient permissions"}`)) - })) - defer srv.Close() - - c := newTestClient(t, srv.URL, "tok") - _, _, err := c.CreateProject("demo") - var typed *output.Error - if !errors.As(err, &typed) || typed.Code != string(cliErrors.INFISICAL_PROJECT_CREATE_FORBIDDEN) { - t.Errorf("expected INFISICAL_PROJECT_CREATE_FORBIDDEN; got %v", err) - } -} - -func TestCreateProject_Unauthorized_401(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - })) - defer srv.Close() - - c := newTestClient(t, srv.URL, "tok") - _, _, err := c.CreateProject("demo") - var typed *output.Error - if !errors.As(err, &typed) || typed.Code != string(cliErrors.INFISICAL_AUTH_FAILED) { - t.Errorf("expected INFISICAL_AUTH_FAILED; got %v", err) - } -} - -func TestCreateProject_OtherError(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte("upstream blew up")) - })) - defer srv.Close() - - c := newTestClient(t, srv.URL, "tok") - _, _, err := c.CreateProject("demo") - var typed *output.Error - if !errors.As(err, &typed) || typed.Code != string(cliErrors.INFISICAL_API_ERROR) { - t.Errorf("expected INFISICAL_API_ERROR for 5xx; got %v", err) - } -} - -func TestCreateProject_MissingToken(t *testing.T) { - c := newTestClient(t, "https://example.invalid", "") - _, _, err := c.CreateProject("demo") - var typed *output.Error - if !errors.As(err, &typed) || typed.Code != string(cliErrors.INFISICAL_AUTH_FAILED) { - t.Errorf("expected INFISICAL_AUTH_FAILED on empty token; got %v", err) - } - if !strings.Contains(err.Error(), "token") { - t.Errorf("expected message to mention token; got %v", err) - } -} - -func TestCreateProject_TrimsTrailingSlashFromSiteURL(t *testing.T) { - hits := 0 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - hits++ - if r.URL.Path != "/api/v2/workspace" { - t.Errorf("path = %q; want /api/v2/workspace (no double-slash)", r.URL.Path) - } - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"project":{"id":"p_id","name":"demo"}}`)) - })) - defer srv.Close() - - c := newTestClient(t, srv.URL+"/", "tok") - if _, _, err := c.CreateProject("demo"); err != nil { - t.Fatalf("CreateProject = %v", err) - } - if hits != 1 { - t.Errorf("expected exactly 1 server hit; got %d", hits) - } -} diff --git a/packages/cli/internal/adapters/env/infisical/config_test.go b/packages/cli/internal/adapters/env/infisical/config_test.go deleted file mode 100644 index d8db63da..00000000 --- a/packages/cli/internal/adapters/env/infisical/config_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package infisical - -import ( - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" -) - -func TestLoadWorkspaceConfig_FromManifest(t *testing.T) { - tmp := t.TempDir() - if err := workspace.WriteManifest(tmp, &workspace.Manifest{ - Version: workspace.ManifestVersion, - - Env: &workspace.EnvironmentConfig{ProjectID: "proj-x", Environments: []string{"dev", "prod"}}, - - Projects: []workspace.ManifestProject{}, - }); err != nil { - t.Fatal(err) - } - - cfg, err := LoadWorkspaceConfig(tmp) - if err != nil { - t.Fatalf("LoadWorkspaceConfig: %v", err) - } - if cfg == nil || cfg.ProjectID != "proj-x" { - t.Fatalf("cfg = %+v; want ProjectID=proj-x", cfg) - } - if cfg.SiteURL != DefaultSiteURL { - t.Fatalf("omitted siteUrl resolved to %q", cfg.SiteURL) - } - if cfg.DefaultEnvOrFallback() != "dev" { - t.Errorf("default env = %q; want dev", cfg.DefaultEnvOrFallback()) - } -} - -func TestLoadWorkspaceConfig_NilWhenNoEnv(t *testing.T) { - tmp := t.TempDir() - if err := workspace.WriteManifest(tmp, &workspace.Manifest{ - Version: workspace.ManifestVersion, - Projects: []workspace.ManifestProject{}, - }); err != nil { - t.Fatal(err) - } - cfg, err := LoadWorkspaceConfig(tmp) - if err != nil { - t.Fatal(err) - } - if cfg != nil { - t.Errorf("expected nil cfg when manifest has no env block; got %+v", cfg) - } -} - -func TestFixedDefaultsIndependentOfOrder(t *testing.T) { - cfg := &WorkspaceConfig{Environments: []string{"prod", "dev"}} - if cfg.DefaultEnvOrFallback() != "dev" || cfg.RootPathOrDefault() != "/" { - t.Fatal(cfg) - } -} - -func TestOmittedSiteCannotUseAnotherSessionInstance(t *testing.T) { - root := t.TempDir() - if err := workspace.WriteManifest(root, &workspace.Manifest{Version: 2, Env: &workspace.EnvironmentConfig{ProjectID: "p", Environments: []string{"dev"}}}); err != nil { - t.Fatal(err) - } - _, _, err := resolveCfgAndCreds(root, &WorkspaceConfig{SiteURL: "https://other.example"}, &Credentials{AccessToken: "synthetic"}) - if err == nil { - t.Fatal("omitted siteUrl followed a different instance") - } -} diff --git a/packages/cli/internal/adapters/env/infisical/ensure_folder_test.go b/packages/cli/internal/adapters/env/infisical/ensure_folder_test.go deleted file mode 100644 index bbfd19ca..00000000 --- a/packages/cli/internal/adapters/env/infisical/ensure_folder_test.go +++ /dev/null @@ -1,131 +0,0 @@ -package infisical - -import ( - "reflect" - "testing" -) - -// folderStepsFor is the only piece of EnsureFolder we can unit-test -// without standing up an Infisical mock — the SDK call itself is a -// one-liner. These cases lock the path-splitting contract: each step -// must name exactly one folder and its parent (which itself was just -// created or already exists). -func TestFolderStepsFor(t *testing.T) { - cases := []struct { - in string - want []folderStep - }{ - {in: "/", want: nil}, - {in: "", want: nil}, - {in: ".", want: nil}, - {in: "/services", want: []folderStep{{Name: "services", Parent: "/"}}}, - {in: "services", want: []folderStep{{Name: "services", Parent: "/"}}}, - {in: "/services/api", want: []folderStep{ - {Name: "services", Parent: "/"}, - {Name: "api", Parent: "/services"}, - }}, - {in: "/a/b/c", want: []folderStep{ - {Name: "a", Parent: "/"}, - {Name: "b", Parent: "/a"}, - {Name: "c", Parent: "/a/b"}, - }}, - // NormalizePath collapses repeats and trims, so this should match - // the simple form. - {in: "//services///api/", want: []folderStep{ - {Name: "services", Parent: "/"}, - {Name: "api", Parent: "/services"}, - }}, - } - for _, c := range cases { - t.Run(c.in, func(t *testing.T) { - got := folderStepsFor(c.in) - if !reflect.DeepEqual(got, c.want) { - t.Errorf("folderStepsFor(%q):\n want: %#v\n got: %#v", c.in, c.want, got) - } - }) - } -} - -// TestParseFolderNotFound locks the regex against the real Infisical -// 404 wire format the user surfaced. Without a regex, the raw multi-line -// SDK dump leaks into the error envelope and is unreadable. -func TestParseFolderNotFound(t *testing.T) { - cases := []struct { - msg string - wantFolder string - wantEnv string - }{ - { - msg: `APIError: CallListSecretsV3Raw unsuccessful response [GET https://app.infisical.com/api/v3/secrets/raw?...] [status-code=404] [reqId=req-X] [message="Folder with path '/apps' in environment 'prod' was not found. Please ensure the environment slug and secret path is correct."]`, - wantFolder: "/apps", - wantEnv: "prod", - }, - { - msg: `Folder with path '/services/api/v2' in environment 'staging' was not found.`, - wantFolder: "/services/api/v2", - wantEnv: "staging", - }, - {msg: `404 Not Found`, wantFolder: "", wantEnv: ""}, - {msg: ``, wantFolder: "", wantEnv: ""}, - } - for _, c := range cases { - t.Run(c.msg[:min(len(c.msg), 40)], func(t *testing.T) { - folder, env := parseFolderNotFound(errFromMsg(c.msg)) - if folder != c.wantFolder || env != c.wantEnv { - t.Errorf("parseFolderNotFound: want (%q,%q), got (%q,%q)", c.wantFolder, c.wantEnv, folder, env) - } - }) - } - folder, env := parseFolderNotFound(nil) - if folder != "" || env != "" { - t.Errorf("parseFolderNotFound(nil): want empty, got (%q,%q)", folder, env) - } -} - -func min(a, b int) int { - if a < b { - return a - } - return b -} - -func TestIsAlreadyExistsError(t *testing.T) { - cases := []struct { - msg string - want bool - }{ - {msg: "folder already exists", want: true}, - {msg: "Already Exists in environment", want: true}, - {msg: "duplicate key", want: true}, - {msg: "409 conflict on /services", want: true}, - {msg: "Conflict: name taken", want: true}, - {msg: "not found", want: false}, - {msg: "404 path not found", want: false}, - {msg: "", want: false}, - } - for _, c := range cases { - t.Run(c.msg, func(t *testing.T) { - got := isAlreadyExistsError(errFromMsg(c.msg)) - if got != c.want { - t.Errorf("isAlreadyExistsError(%q): want %v, got %v", c.msg, c.want, got) - } - }) - } - if isAlreadyExistsError(nil) { - t.Errorf("isAlreadyExistsError(nil) must be false") - } -} - -// errFromMsg returns a tiny error wrapping msg without depending on the -// errors package's Wrap / fmt.Errorf — keeps the test focused on -// substring matching, not error chain traversal. -type stringErr string - -func (e stringErr) Error() string { return string(e) } - -func errFromMsg(msg string) error { - if msg == "" { - return nil - } - return stringErr(msg) -} diff --git a/packages/cli/internal/adapters/env/infisical/fetch_batch_test.go b/packages/cli/internal/adapters/env/infisical/fetch_batch_test.go deleted file mode 100644 index 6fb3b8c8..00000000 --- a/packages/cli/internal/adapters/env/infisical/fetch_batch_test.go +++ /dev/null @@ -1,295 +0,0 @@ -package infisical - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - "net/http/httptest" - "reflect" - "strings" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/infisical/go-sdk/packages/models" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" - "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/secrets" - "github.com/zalando/go-keyring" -) - -func batchWorkspace(t *testing.T, handler http.HandlerFunc) string { - t.Helper() - keyring.MockInit() - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - server := httptest.NewServer(handler) - t.Cleanup(server.Close) - sharedTestSession(t, server.URL, "user") - root := t.TempDir() - if err := workspace.WriteManifest(root, &workspace.Manifest{Version: 2, Env: &workspace.EnvironmentConfig{SiteURL: server.URL, ProjectID: "remote", Environments: []string{"dev", "staging"}}}); err != nil { - t.Fatal(err) - } - return root -} - -func waitSnapshotRequest(t *testing.T, started <-chan struct{}) { - t.Helper() - select { - case <-started: - case <-time.After(5 * time.Second): - t.Fatal("snapshot request did not start") - } -} - -func TestBatchFetchReadsOnceAndMergesFreshSnapshots(t *testing.T) { - var calls, generation atomic.Int32 - root := batchWorkspace(t, func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - if r.Method != http.MethodGet || r.URL.Path != "/api/v3/secrets/raw" || q.Get("workspaceId") != "remote" || q.Get("environment") != "dev" || q.Get("secretPath") != "/" || q.Get("expandSecretReferences") != "true" || q.Get("recursive") != "true" || q.Get("include_imports") != "false" || r.Header.Get("Authorization") == "" { - t.Errorf("unexpected request %s %s", r.Method, r.URL) - } - calls.Add(1) - // Children precede their parents in the response. Only inheritance - // order, never response order, may decide which value wins. - folders := []struct { - path string - values map[string]string - }{ - {"/services/server", map[string]string{"OVERRIDE": "server", "SERVER_ONLY": "yes"}}, - {"/apps/home", map[string]string{"OVERRIDE": "home", "HOME_ONLY": "yes"}}, - {"/apps/home/nested", map[string]string{"NESTED_ONLY": "yes", "OVERRIDE": "nested"}}, - {"/apps/home-other", map[string]string{"PREFIX_ONLY": "yes"}}, - {"/unrelated", map[string]string{"UNRELATED_ONLY": "yes"}}, - {"/apps", map[string]string{"OVERRIDE": "apps"}}, - {"/services", map[string]string{"OVERRIDE": "services"}}, - {"/", map[string]string{"SHARED": "root", "OVERRIDE": "root"}}, - } - values := []models.Secret{} - for _, folder := range folders { - for key, value := range folder.values { - values = append(values, models.Secret{SecretPath: folder.path, SecretKey: key, SecretValue: fmt.Sprintf("%d:%s", generation.Load(), value)}) - } - } - values = append(values, models.Secret{SecretPath: "/", SecretKey: "EMPTY", SecretValue: ""}) - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"secrets": values}) - }) - dirs := []string{"services/server", "apps/home", "apps/admin", "apps/home", "", "missing/project"} - got, err := secrets.LoadProjects(context.Background(), Loader(), root, dirs, "dev") - if err != nil { - t.Fatal(err) - } - if calls.Load() != 1 || len(got) != 5 { - t.Fatalf("requests = %d, snapshots = %d", calls.Load(), len(got)) - } - for _, tc := range []struct{ dir, override string }{{"services/server", "server"}, {"apps/home", "home"}, {"apps/admin", "apps"}, {"", "root"}, {"missing/project", "root"}} { - values := got[tc.dir] - if values["SHARED"] != "0:root" || values["OVERRIDE"] != "0:"+tc.override { - t.Fatalf("wrong inheritance: %v", got) - } - if empty, exists := values["EMPTY"]; !exists || empty != "" { - t.Fatal("empty value was lost") - } - for _, key := range []string{"NESTED_ONLY", "PREFIX_ONLY", "UNRELATED_ONLY"} { - if _, exists := values[key]; exists { - t.Fatalf("%s leaked into %s", key, tc.dir) - } - } - } - if got["apps/home"]["SERVER_ONLY"] != "" || got["apps/admin"]["HOME_ONLY"] != "" || got[""]["HOME_ONLY"] != "" { - t.Fatal("project values leaked") - } - got["apps/home"]["SHARED"] = "modified" - if got["apps/admin"]["SHARED"] != "0:root" { - t.Fatal("project maps alias") - } - generation.Store(1) - next, err := secrets.LoadProjects(context.Background(), Loader(), root, dirs, "dev") - if err != nil { - t.Fatal(err) - } - if next["apps/home"]["SHARED"] != "1:root" || calls.Load() != 2 { - t.Fatal("a later invocation did not read exactly one fresh snapshot") - } -} - -func TestBatchFetchReadsManyProjectsOnce(t *testing.T) { - var calls atomic.Int32 - root := batchWorkspace(t, func(w http.ResponseWriter, r *http.Request) { - calls.Add(1) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"secrets":[]}`)) - }) - dirs := []string{} - for i := range 20 { - dirs = append(dirs, fmt.Sprintf("apps/p%d", i)) - } - got, err := fetchSecretsForProjects(context.Background(), root, dirs, "dev") - if err != nil || len(got) != len(dirs) || calls.Load() != 1 { - t.Fatalf("snapshots = %d, requests = %d, error = %v", len(got), calls.Load(), err) - } - for _, dir := range dirs { - if got[dir] == nil || len(got[dir]) != 0 { - t.Fatalf("missing empty snapshot for %s", dir) - } - } - got, err = fetchSecretsForProjects(context.Background(), t.TempDir(), nil, "dev") - if err != nil || len(got) != 0 || calls.Load() != 1 { - t.Fatal("empty batch required configuration or performed a request") - } -} - -func TestBatchFetchFailureReturnsNoSnapshot(t *testing.T) { - for _, tc := range []struct { - name string - status int - code string - }{ - {"unauthorized", 401, "INFISICAL_AUTH_FAILED"}, - {"forbidden", 403, "INFISICAL_API_ERROR"}, - {"project_missing", 404, "INFISICAL_API_ERROR"}, - {"rate_limited", 429, "INFISICAL_API_ERROR"}, - {"server_error", 500, "INFISICAL_API_ERROR"}, - {"connection_closed", 0, "INFISICAL_API_ERROR"}, - {"invalid_json", 200, "INFISICAL_API_ERROR"}, - } { - t.Run(tc.name, func(t *testing.T) { - var calls atomic.Int32 - root := batchWorkspace(t, func(w http.ResponseWriter, r *http.Request) { - calls.Add(1) - if tc.status == 0 { - conn, _, err := w.(http.Hijacker).Hijack() - if err == nil { - _ = conn.Close() - } - return - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(tc.status) - if tc.status == 200 { - _, _ = w.Write([]byte(`{"secrets":`)) - return - } - _, _ = w.Write([]byte(`{"message":"request rejected: synthetic-secret-value"}`)) - }) - values, err := fetchSecretsForProjects(context.Background(), root, []string{"services/server", "apps/home", "apps/admin"}, "dev") - var coded interface{ ErrorCode() string } - if values != nil || !errors.As(err, &coded) || coded.ErrorCode() != tc.code || calls.Load() != 1 { - t.Fatalf("error = %v, requests = %d, want %s", err, calls.Load(), tc.code) - } - if strings.Contains(err.Error(), "synthetic-secret-value") { - t.Fatal("error exposed the response body") - } - }) - } -} - -func TestBatchFetchRejectsMissingSecretPaths(t *testing.T) { - for _, locale := range []string{"en-US", "zh-CN"} { - t.Run(locale, func(t *testing.T) { - if err := i18n.Init(locale); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = i18n.Init("en-US") }) - root := batchWorkspace(t, func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"secrets":[{"secretPath":"/apps/home","secretKey":"VALID","secretValue":"synthetic-secret-value"},{"secretKey":"PRIVATE","secretValue":"synthetic-secret-value"}]}`)) - }) - values, err := fetchSecretsForProjects(context.Background(), root, []string{"apps/home", "apps/admin"}, "dev") - var coded interface{ ErrorCode() string } - if values != nil || !errors.As(err, &coded) || coded.ErrorCode() != "INFISICAL_API_ERROR" || err.Error() != i18n.T("infisical.snapshot_path_missing") { - t.Fatalf("missing secret path was not rejected: %v", err) - } - if strings.Contains(err.Error(), "synthetic-secret-value") { - t.Fatal("error exposed secret values") - } - }) - } -} - -func TestBatchFetchChecksRecursiveDepth(t *testing.T) { - dir := strings.Repeat("nested/", maxSnapshotDepth-1) + "project" - var calls atomic.Int32 - root := batchWorkspace(t, func(w http.ResponseWriter, r *http.Request) { - calls.Add(1) - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"secrets": []models.Secret{{SecretPath: "/" + dir, SecretKey: "DEEPEST", SecretValue: "yes"}}}) - }) - got, err := fetchSecretsForProjects(context.Background(), root, []string{dir}, "dev") - if err != nil || got[dir]["DEEPEST"] != "yes" || calls.Load() != 1 { - t.Fatalf("supported depth failed: %v %v", got, err) - } - for _, locale := range []string{"en-US", "zh-CN"} { - t.Run(locale, func(t *testing.T) { - if err := i18n.Init(locale); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = i18n.Init("en-US") }) - tooDeep := dir + "/child" - got, err := fetchSecretsForProjects(context.Background(), root, []string{"apps/home", tooDeep}, "dev") - if got != nil || err == nil || err.Error() != i18n.Tf("infisical.snapshot_depth_exceeded", tooDeep, maxSnapshotDepth) || calls.Load() != 1 { - t.Fatalf("unsupported depth was not rejected before fetching: %v", err) - } - }) - } -} - -func TestBatchFetchCancellation(t *testing.T) { - started := make(chan struct{}, 1) - cancelled := make(chan struct{}, 1) - var calls atomic.Int32 - root := batchWorkspace(t, func(w http.ResponseWriter, r *http.Request) { - calls.Add(1) - started <- struct{}{} - <-r.Context().Done() - cancelled <- struct{}{} - }) - ctx, cancel := context.WithCancel(context.Background()) - t.Cleanup(cancel) - done := make(chan error, 1) - go func() { - _, err := fetchSecretsForProjects(ctx, root, []string{"apps/home", "apps/admin"}, "dev") - done <- err - }() - waitSnapshotRequest(t, started) - cancel() - select { - case err := <-done: - if !errors.Is(err, context.Canceled) { - t.Fatalf("error = %v", err) - } - case <-time.After(3 * time.Second): - t.Fatal("HTTP request ignored cancellation") - } - waitSnapshotRequest(t, cancelled) - values, err := fetchSecretsForProjects(ctx, root, []string{"apps/home"}, "dev") - if !errors.Is(err, context.Canceled) || values != nil || calls.Load() != 1 { - t.Fatal("pre-cancelled load did work") - } -} - -func TestBatchFetchEnvironmentIsolation(t *testing.T) { - var calls atomic.Int32 - root := batchWorkspace(t, func(w http.ResponseWriter, r *http.Request) { - calls.Add(1) - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"secrets": []models.Secret{{SecretPath: "/", SecretKey: "ENV", SecretValue: r.URL.Query().Get("environment")}}}) - }) - var wg sync.WaitGroup - for _, env := range []string{"dev", "staging"} { - wg.Go(func() { - got, err := fetchSecretsForProjects(context.Background(), root, []string{"apps/home", "apps/admin"}, env) - want := map[string]map[string]string{"apps/home": {"ENV": env}, "apps/admin": {"ENV": env}} - if err != nil || !reflect.DeepEqual(got, want) { - t.Errorf("%s: %v %v", env, got, err) - } - }) - } - wg.Wait() - if calls.Load() != 2 { - t.Fatalf("requests = %d, want one per environment", calls.Load()) - } -} diff --git a/packages/cli/internal/adapters/env/infisical/fetch_test.go b/packages/cli/internal/adapters/env/infisical/fetch_test.go deleted file mode 100644 index 1bd97b33..00000000 --- a/packages/cli/internal/adapters/env/infisical/fetch_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package infisical - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "os" - "reflect" - "sync" - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" - "github.com/zalando/go-keyring" -) - -func TestFetchProjectEnvironmentsStayIsolated(t *testing.T) { - keyring.MockInit() - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - folders := map[string]map[string]string{ - "/": {"SHARED": "root", "OVERRIDE": "root"}, - "/services": {"PARENT": "services", "OVERRIDE": "parent"}, - "/services/api": {"API_ONLY": "api", "OVERRIDE": "api"}, - "/services/worker": {"WORKER_ONLY": "worker", "OVERRIDE": "worker"}, - } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - if r.URL.Path != "/api/v3/secrets/raw" || q.Get("workspaceId") != "remote" || q.Get("recursive") != "true" || q.Get("secretPath") != "/" { - t.Errorf("unexpected request %s", r.URL) - } - env := q.Get("environment") - if env != "dev" && env != "staging" { - t.Errorf("unexpected environment %q", env) - } - values := []map[string]string{} - for path, folder := range folders { - for k, v := range folder { - values = append(values, map[string]string{"secretPath": path, "secretKey": k, "secretValue": env + ":" + v}) - } - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"secrets": values}) - })) - defer server.Close() - sharedTestSession(t, server.URL, "user") - root := t.TempDir() - if err := workspace.WriteManifest(root, &workspace.Manifest{Version: 2, Env: &workspace.EnvironmentConfig{SiteURL: server.URL, ProjectID: "remote", Environments: []string{"staging", "dev"}}, Projects: []workspace.ManifestProject{ - {Name: "api", RelativeDir: "services/api", Toolchain: "go"}, {Name: "worker", RelativeDir: "services/worker", Toolchain: "go"}, - }}); err != nil { - t.Fatal(err) - } - before, _ := os.ReadFile(workspace.ManifestPath(root)) - var wg sync.WaitGroup - for _, project := range []string{"api", "worker"} { - for _, requested := range []string{"", "staging"} { - wg.Go(func() { - got, err := FetchSecretsForSubproject(context.Background(), root, "services/"+project, requested) - if err != nil { - t.Error(err) - return - } - env := requested - if env == "" { - env = "dev" - } - want := map[string]string{"SHARED": env + ":root", "PARENT": env + ":services", "OVERRIDE": env + ":" + project} - key := "API_ONLY" - if project == "worker" { - key = "WORKER_ONLY" - } - want[key] = env + ":" + project - if !reflect.DeepEqual(got, want) { - t.Error(fmt.Sprintf("%s/%s received wrong scope: %v", project, env, got)) - } - }) - } - } - wg.Wait() - after, _ := os.ReadFile(workspace.ManifestPath(root)) - if !bytes.Equal(before, after) { - t.Fatal("fetch changed manifest") - } -} diff --git a/packages/cli/internal/adapters/env/infisical/global_session_test.go b/packages/cli/internal/adapters/env/infisical/global_session_test.go deleted file mode 100644 index 583c867f..00000000 --- a/packages/cli/internal/adapters/env/infisical/global_session_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package infisical - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "testing" - "time" - - session "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/infisicalsession" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" - "github.com/zalando/go-keyring" -) - -func TestGlobalBindingPreservesSavedLocationWhenSessionChanges(t *testing.T) { - for _, change := range []string{"logout", "account"} { - t.Run(change, func(t *testing.T) { - keyring.MockInit() - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - var original session.Session - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/workspace/remote" || r.Header.Get("Authorization") != "Bearer test-token" { - t.Errorf("unexpected metadata request: %s", r.URL) - } - if change == "logout" { - if err := session.Logout(); err != nil { - t.Error(err) - } - } else { - current := original - current.UserID = "another-user" - raw, _ := json.Marshal(current) - if err := keyring.Set("one-cli.infisical", "session", string(raw)); err != nil { - t.Error(err) - } - } - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"workspace":{"id":"remote","name":"Shared","type":"secret-manager","orgId":"org","environments":[{"slug":"dev"}]}}`) - })) - defer upstream.Close() - original = session.Session{Info: session.Info{SiteURL: upstream.URL, UserID: "user", OrganizationID: "org", ExpiresAt: time.Now().Add(time.Hour)}, Token: "test-token"} - raw, _ := json.Marshal(original) - if err := keyring.Set("one-cli.infisical", "session", string(raw)); err != nil { - t.Fatal(err) - } - path, err := session.ConfigPath("global-env.json") - if err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { - t.Fatal(err) - } - before := []byte(`{"projectId":"previous","defaultEnvironment":"prod"}`) - if err := os.WriteFile(path, before, 0600); err != nil { - t.Fatal(err) - } - _, err = BindGlobal(context.Background(), "remote", "dev") - wantCode := "INFISICAL_AUTH_FAILED" - if change == "logout" { - wantCode = "INFISICAL_AUTH_MISSING" - } - var coded *output.Error - if !errors.As(err, &coded) || coded.Code != wantCode { - t.Fatalf("unexpected session failure: %v", err) - } - after, err := os.ReadFile(path) - if err != nil || string(after) != string(before) { - t.Fatalf("changed session overwrote the saved location: %v", err) - } - }) - } -} diff --git a/packages/cli/internal/adapters/env/infisical/global_test.go b/packages/cli/internal/adapters/env/infisical/global_test.go deleted file mode 100644 index e2afdc18..00000000 --- a/packages/cli/internal/adapters/env/infisical/global_test.go +++ /dev/null @@ -1,126 +0,0 @@ -package infisical - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - session "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/infisicalsession" - "github.com/zalando/go-keyring" -) - -func TestGlobalLocationAndListingStayScoped(t *testing.T) { - keyring.MockInit() - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - var secretsCalled int - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if r.Header.Get("Authorization") != "Bearer test-token" { - http.Error(w, "bad token", 401) - return - } - switch { - case r.URL.Path == "/api/v1/workspace/project-1": - w.Write([]byte(`{"workspace":{"type":"secret-manager","id":"project-1","name":"Shared","orgId":"org-1","environments":[{"slug":"dev","name":"Development"},{"slug":"prod","name":"Production"}]}}`)) - case strings.Contains(r.URL.Path, "folders"): - if r.URL.Query().Get("path") != "/docker" { - t.Errorf("folder request: %s", r.URL) - } - w.Write([]byte(`{"folders":[{"id":"child","name":"nested"}]}`)) - case strings.Contains(r.URL.Path, "secrets"): - secretsCalled++ - if r.URL.Query().Get("recursive") == "true" { - t.Error("recursive read") - } - w.Write([]byte(`{"secrets":[{"secretKey":"PASSWORD","secretValue":"never-list-this","secretComment":"Registry password"}]}`)) - default: - t.Errorf("unexpected request %s", r.URL) - http.NotFound(w, r) - } - })) - defer upstream.Close() - store := func(user string) { - raw, _ := json.Marshal(session.Session{Info: session.Info{SiteURL: upstream.URL, UserID: user, OrganizationID: "org-1", ExpiresAt: time.Now().Add(time.Hour)}, Token: "test-token"}) - if e := keyring.Set("one-cli.infisical", "session", string(raw)); e != nil { - t.Fatal(e) - } - } - store("user-1") - ctx := context.Background() - if _, e := BindGlobal(ctx, "project-1", "missing"); e == nil { - t.Fatal("bound missing env") - } - if _, e := BindGlobal(ctx, "project-1", "dev"); e != nil { - t.Fatal(e) - } - listing, e := ListGlobal(ctx, "prod", "/docker") - if e != nil { - t.Fatal(e) - } - raw, _ := json.Marshal(listing) - if strings.Contains(string(raw), "never-list-this") { - t.Fatal("list leaked value") - } - if len(listing.Folders) != 1 || listing.Folders[0] != "/docker/nested" { - t.Fatal(listing.Folders) - } - if _, e := GlobalValues(ctx, "missing", "/docker", nil); e == nil { - t.Fatal("invalid environment fell back") - } - if secretsCalled != 1 { - t.Fatal("fetched secrets for invalid environment") - } - store("user-2") - if _, e := ListGlobal(ctx, "prod", "/docker"); e == nil { - t.Fatal("account mismatch accepted") - } -} -func TestGlobalPathRejectsTraversal(t *testing.T) { - for _, p := range []string{"relative", "/a/../b", "/a/./b", "/a\\b", "/a\nb"} { - if _, e := ValidateGlobalPath(p); e == nil { - t.Errorf("accepted %q", p) - } - } -} - -func TestGlobalSelectedKeysDoNotReadOtherValuesOrExpandReferences(t *testing.T) { - keyring.MockInit() - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - reads := 0 - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch r.URL.Path { - case "/api/v1/workspace/shared": - w.Write([]byte(`{"workspace":{"type":"secret-manager","id":"shared","orgId":"org","environments":[{"slug":"prod"}]}}`)) - case "/api/v3/secrets/raw/AK": - reads++ - q := r.URL.Query() - if q.Get("environment") != "prod" || q.Get("secretPath") != "/oss" || q.Get("expandSecretReferences") == "true" || q.Get("include_imports") == "true" { - t.Errorf("scope widened: %s", r.URL) - } - w.Write([]byte(`{"secret":{"secretKey":"AK","secretValue":"selected-value"}}`)) - default: - t.Errorf("unexpected value read: %s", r.URL) - http.NotFound(w, r) - } - })) - defer upstream.Close() - raw, _ := json.Marshal(session.Session{Info: session.Info{SiteURL: upstream.URL, UserID: "user", OrganizationID: "org", ExpiresAt: time.Now().Add(time.Hour)}, Token: "test-token"}) - if err := keyring.Set("one-cli.infisical", "session", string(raw)); err != nil { - t.Fatal(err) - } - if _, err := BindGlobal(context.Background(), "shared", "prod"); err != nil { - t.Fatal(err) - } - values, err := GlobalValues(context.Background(), "prod", "/oss", []string{"AK"}) - if err != nil { - t.Fatal(err) - } - if reads != 1 || len(values) != 1 || values["AK"] != "selected-value" { - t.Fatalf("values=%v reads=%d", values, reads) - } -} diff --git a/packages/cli/internal/adapters/env/infisical/init_test.go b/packages/cli/internal/adapters/env/infisical/init_test.go deleted file mode 100644 index 6ceb9b85..00000000 --- a/packages/cli/internal/adapters/env/infisical/init_test.go +++ /dev/null @@ -1,244 +0,0 @@ -package infisical - -// Coverage for the pure helpers in init.go that don't need an Infisical -// server. The auto-create flow (Init itself) is exercised end-to-end -// against an httptest backend in client_projects_test.go; here we pin -// the supporting logic that decides defaults, project names, and the -// manifest back-fill behavior. - -import ( - "os" - "path/filepath" - "reflect" - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" -) - -func TestApplyInitDefaults(t *testing.T) { - got := applyInitDefaults(InitInput{}) - if !reflect.DeepEqual(got.Environments, []string{"dev", "staging", "prod"}) { - t.Fatal(got) - } - custom := applyInitDefaults(InitInput{Environments: []string{"prod", "dev"}}) - if !reflect.DeepEqual(custom.Environments, []string{"prod", "dev"}) { - t.Fatal(custom) - } -} - -func TestRandomSuffix_DeterministicLength(t *testing.T) { - for _, n := range []int{4, 8, 12} { - got := randomSuffix(n) - if len(got) != n { - t.Errorf("randomSuffix(%d) length = %d", n, len(got)) - } - } -} - -func TestRandomSuffix_HexAlphabet(t *testing.T) { - got := randomSuffix(16) - for i, c := range got { - switch { - case c >= '0' && c <= '9': - case c >= 'a' && c <= 'f': - default: - t.Errorf("randomSuffix produced non-hex char %q at offset %d (full: %q)", c, i, got) - } - } -} - -func TestRandomSuffix_LowCollisionRate(t *testing.T) { - // Cheap statistical sanity check: 200 calls of 4-char hex (16-bit - // space) should produce way more than ~10 unique values. If random - // is silently broken (e.g. always-zero fallback), this catches it. - seen := map[string]bool{} - for i := 0; i < 200; i++ { - seen[randomSuffix(4)] = true - } - if len(seen) < 100 { - t.Errorf("randomSuffix appears non-random: only %d unique values from 200 calls", len(seen)) - } -} - -func TestDedupeStrings(t *testing.T) { - cases := []struct { - name string - in []string - want []string - }{ - {name: "empty", in: nil, want: []string{}}, - {name: "no dups, no whitespace", in: []string{"a", "b", "c"}, want: []string{"a", "b", "c"}}, - {name: "drop exact dup", in: []string{"a", "a", "b"}, want: []string{"a", "b"}}, - {name: "trim whitespace before dedup", in: []string{"a", " a ", "b"}, want: []string{"a", "b"}}, - {name: "drop blank entries", in: []string{"", " ", "x", ""}, want: []string{"x"}}, - {name: "preserve first-seen order", in: []string{"c", "a", "c", "b"}, want: []string{"c", "a", "b"}}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got := dedupeStrings(tc.in) - if !reflect.DeepEqual(got, tc.want) { - t.Errorf("got %v, want %v", got, tc.want) - } - }) - } -} - -func TestResolveProjectName_OverrideWins(t *testing.T) { - tmp := t.TempDir() - got, err := resolveProjectName(tmp, "explicit-name") - if err != nil { - t.Fatal(err) - } - if got != "explicit-name" { - t.Errorf("override should win: got %q", got) - } -} - -func TestResolveProjectName_OverrideTrimsWhitespace(t *testing.T) { - tmp := t.TempDir() - got, err := resolveProjectName(tmp, " spaced-name ") - if err != nil { - t.Fatal(err) - } - if got != "spaced-name" { - t.Errorf("override should trim: got %q", got) - } -} - -func TestResolveProjectName_FromManifest(t *testing.T) { - tmp := t.TempDir() - mustWriteManifest(t, tmp, &workspace.Manifest{ - Version: workspace.ManifestVersion, - Workspace: &workspace.ManifestWorkspace{ID: "ws_abc", Name: "manifest-name"}, - Projects: []workspace.ManifestProject{}, - }) - - got, err := resolveProjectName(tmp, "") - if err != nil { - t.Fatal(err) - } - if got != "manifest-name" { - t.Errorf("manifest should win when override empty: got %q", got) - } -} - -func TestResolveProjectName_FallsBackToPackageJSON(t *testing.T) { - tmp := t.TempDir() - mustWritePackageJSON(t, tmp, `{"name":"pkg-name","version":"0.1.0"}`) - - got, err := resolveProjectName(tmp, "") - if err != nil { - t.Fatal(err) - } - if got != "pkg-name" { - t.Errorf("package.json should be used when manifest empty: got %q", got) - } -} - -func TestResolveProjectName_FallsBackToDirBasename(t *testing.T) { - parent := t.TempDir() - tmp := filepath.Join(parent, "billing-platform") - if err := os.MkdirAll(tmp, 0o755); err != nil { - t.Fatal(err) - } - - got, err := resolveProjectName(tmp, "") - if err != nil { - t.Fatal(err) - } - if got != "billing-platform" { - t.Errorf("dir basename fallback: got %q", got) - } -} - -func TestResolveProjectName_PrecedenceOrder(t *testing.T) { - // All three sources present — manifest must beat package.json must - // beat dir basename. - parent := t.TempDir() - tmp := filepath.Join(parent, "dirname-loses") - if err := os.MkdirAll(tmp, 0o755); err != nil { - t.Fatal(err) - } - mustWritePackageJSON(t, tmp, `{"name":"pkgname-loses"}`) - mustWriteManifest(t, tmp, &workspace.Manifest{ - Version: workspace.ManifestVersion, - Workspace: &workspace.ManifestWorkspace{ID: "ws_x", Name: "manifest-wins"}, - Projects: []workspace.ManifestProject{}, - }) - - got, err := resolveProjectName(tmp, "") - if err != nil { - t.Fatal(err) - } - if got != "manifest-wins" { - t.Errorf("precedence broken: got %q", got) - } -} - -func TestEnsureManifestProject_BackfillsMissingBlock(t *testing.T) { - tmp := t.TempDir() - // Write a v2 manifest with NO Project block. - mustWriteManifest(t, tmp, &workspace.Manifest{ - Version: workspace.ManifestVersion, - Projects: []workspace.ManifestProject{}, - }) - - if err := ensureManifestProject(tmp, "auto-name"); err != nil { - t.Fatal(err) - } - - loaded, err := workspace.ReadManifest(tmp) - if err != nil { - t.Fatal(err) - } - if loaded.Workspace == nil { - t.Fatal("Project block not back-filled") - } - if loaded.Workspace.Name != "auto-name" { - t.Errorf("backfill name: want auto-name, got %q", loaded.Workspace.Name) - } - if loaded.Workspace.ID == "" { - t.Error("backfill ID should be non-empty (GenerateProjectID)") - } -} - -func TestEnsureManifestProject_PreservesExisting(t *testing.T) { - tmp := t.TempDir() - mustWriteManifest(t, tmp, &workspace.Manifest{ - Version: workspace.ManifestVersion, - Workspace: &workspace.ManifestWorkspace{ - ID: "ws_preexisting", Name: "preexisting-name", - }, - Projects: []workspace.ManifestProject{}, - }) - - if err := ensureManifestProject(tmp, "ignored-fallback"); err != nil { - t.Fatal(err) - } - - loaded, _ := workspace.ReadManifest(tmp) - if loaded.Workspace.ID != "ws_preexisting" { - t.Errorf("ID should not be overwritten: got %q", loaded.Workspace.ID) - } - if loaded.Workspace.Name != "preexisting-name" { - t.Errorf("Name should not be overwritten: got %q", loaded.Workspace.Name) - } -} - -func mustWriteManifest(t *testing.T, dir string, m *workspace.Manifest) { - t.Helper() - b, err := workspace.MarshalManifest(m) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, workspace.ManifestFilename), append(b, '\n'), 0o644); err != nil { - t.Fatal(err) - } -} - -func mustWritePackageJSON(t *testing.T, dir string, body string) { - t.Helper() - if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(body), 0o644); err != nil { - t.Fatal(err) - } -} diff --git a/packages/cli/internal/adapters/env/infisical/paths_test.go b/packages/cli/internal/adapters/env/infisical/paths_test.go deleted file mode 100644 index 8ddb91b8..00000000 --- a/packages/cli/internal/adapters/env/infisical/paths_test.go +++ /dev/null @@ -1,128 +0,0 @@ -package infisical - -import ( - "reflect" - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" -) - -func TestNormalizePath(t *testing.T) { - cases := []struct { - in, want string - }{ - {"", "/"}, - {".", "/"}, - {"/", "/"}, - {"x", "/x"}, - {"x/y", "/x/y"}, - {"/x/y", "/x/y"}, - {"/x/y/", "/x/y"}, - {"//x///y/", "/x/y"}, - {`x\y`, "/x/y"}, - } - for _, tc := range cases { - if got := NormalizePath(tc.in); got != tc.want { - t.Errorf("NormalizePath(%q) = %q; want %q", tc.in, got, tc.want) - } - } -} - -func TestSanitizeEnvName(t *testing.T) { - good := []string{"dev", "staging", "prod", "review-123", "qa_2"} - for _, s := range good { - if _, err := SanitizeEnvName(s); err != nil { - t.Errorf("SanitizeEnvName(%q) unexpected err: %v", s, err) - } - } - bad := []string{"", " ", "_x", "-x", "x y", "x/y", "x\tcolon"} - for _, s := range bad { - if _, err := SanitizeEnvName(s); err == nil { - t.Errorf("SanitizeEnvName(%q) expected error, got none", s) - } - } -} - -func TestAssertValidKey(t *testing.T) { - good := []string{"DATABASE_URL", "_PRIVATE", "k", "X1", "MY_KEY_2"} - for _, k := range good { - if err := AssertValidKey(k); err != nil { - t.Errorf("AssertValidKey(%q) unexpected err: %v", k, err) - } - } - bad := []string{"", "1KEY", "MY-KEY", "MY KEY", "key.path"} - for _, k := range bad { - if err := AssertValidKey(k); err == nil { - t.Errorf("AssertValidKey(%q) expected error, got none", k) - } - } -} - -func TestPathInheritanceChain(t *testing.T) { - cases := []struct { - name string - root string - path string - inherits bool - want []string - }{ - { - name: "no inherits returns leaf only", - root: "/", - path: "/services/user-api", - inherits: false, - want: []string{"/services/user-api"}, - }, - { - name: "inherits walks segments from root", - root: "/", - path: "/services/user-api", - inherits: true, - want: []string{"/", "/services", "/services/user-api"}, - }, - { - name: "inherits with non-default rootPath", - root: "/team-foo", - path: "/team-foo/web/dashboard", - inherits: true, - want: []string{"/team-foo", "/team-foo/web", "/team-foo/web/dashboard"}, - }, - { - name: "path equals root", - root: "/", - path: "/", - inherits: true, - want: []string{"/"}, - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got := pathInheritanceChain(tc.root, tc.path, tc.inherits) - if !reflect.DeepEqual(got, tc.want) { - t.Errorf("got=%v want=%v", got, tc.want) - } - }) - } -} - -func TestResolveSubprojectPath_DefaultsFromRelativeDir(t *testing.T) { - sub := &workspace.Project{Name: "user-api", RelativeDir: "services/user-api"} - res := ResolveSubprojectPath(sub) - if res.Path != "/services/user-api" { - t.Errorf("default path wrong: %v", res.Path) - } - if !res.Inherits { - t.Errorf("default Inherits should be true") - } -} - -func TestProjectPathIncludesOnlyAncestors(t *testing.T) { - res := ResolveSubprojectPath(&workspace.Project{RelativeDir: "apps/web"}) - if !reflect.DeepEqual(res.Chain, []string{"/", "/apps", "/apps/web"}) { - t.Fatal(res) - } - root := ResolveSubprojectPath(nil) - if !reflect.DeepEqual(root.Chain, []string{"/"}) { - t.Fatal(root) - } -} diff --git a/packages/cli/internal/adapters/env/infisical/shared_location_test.go b/packages/cli/internal/adapters/env/infisical/shared_location_test.go deleted file mode 100644 index 79424442..00000000 --- a/packages/cli/internal/adapters/env/infisical/shared_location_test.go +++ /dev/null @@ -1,191 +0,0 @@ -package infisical - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "sync" - "testing" - "time" - - session "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/infisicalsession" - "github.com/zalando/go-keyring" -) - -func sharedTestSession(t *testing.T, site, user string) { - t.Helper() - raw, _ := json.Marshal(session.Session{Info: session.Info{SiteURL: site, UserID: user, OrganizationID: "org", ExpiresAt: time.Now().Add(time.Hour)}, Token: "test-token"}) - if err := keyring.Set("one-cli.infisical", "session", string(raw)); err != nil { - t.Fatal(err) - } -} - -func TestDefaultSharedLocationConcurrentSetupAndPreservation(t *testing.T) { - keyring.MockInit() - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - var mu sync.Mutex - creates := 0 - project := RemoteProject{ID: "shared", Name: DefaultSharedProject, Type: "secret-manager", OrganizationID: "org", Environments: []RemoteEnvironment{{Slug: "dev"}}} - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - mu.Lock() - defer mu.Unlock() - w.Header().Set("Content-Type", "application/json") - switch r.URL.Path { - case "/api/v1/workspace": - json.NewEncoder(w).Encode(map[string]any{"workspaces": []RemoteProject{}}) - case "/api/v2/workspace": - creates++ - var body map[string]string - json.NewDecoder(r.Body).Decode(&body) - if body["projectName"] != DefaultSharedProject || body["type"] != "secret-manager" || r.Method != "POST" || r.Header.Get("Authorization") != "Bearer test-token" { - t.Errorf("invalid project creation: %v", body) - } - json.NewEncoder(w).Encode(map[string]any{"project": project}) - case "/api/v1/workspace/shared": - json.NewEncoder(w).Encode(map[string]any{"workspace": project}) - case "/api/v1/workspace/custom": - json.NewEncoder(w).Encode(map[string]any{"workspace": RemoteProject{ID: "custom", Name: "Custom", Type: "secret-manager", OrganizationID: "org", Environments: []RemoteEnvironment{{Slug: "prod"}}}}) - default: - t.Errorf("unexpected request: %s", r.URL) - http.NotFound(w, r) - } - })) - defer upstream.Close() - sharedTestSession(t, upstream.URL, "user") - ctx := context.Background() - results := make(chan error, 4) - for range 4 { - go func() { - location, err := EnsureDefaultGlobal(ctx) - if err == nil && (location.ProjectID != "shared" || location.DefaultEnvironment != "dev") { - t.Errorf("unexpected location: %+v", location) - } - results <- err - }() - } - for range 4 { - if err := <-results; err != nil { - t.Fatal(err) - } - } - if creates != 1 { - t.Fatalf("created %d projects", creates) - } - if _, err := BindGlobal(ctx, "custom", "prod"); err != nil { - t.Fatal(err) - } - location, err := EnsureDefaultGlobal(ctx) - if err != nil || location.ProjectID != "custom" || location.DefaultEnvironment != "prod" { - t.Fatalf("existing location overwritten: %+v %v", location, err) - } - sharedTestSession(t, upstream.URL, "different-user") - if _, err := EnsureDefaultGlobal(ctx); err == nil { - t.Fatal("accepted another account's location") - } - location, _ = LoadGlobalLocation() - if location.UserID != "user" { - t.Fatal("overwrote location after account change") - } -} - -func TestSharedProjectFilteringAndDefaultReuse(t *testing.T) { - for _, env := range []string{"dev", "prod"} { - t.Run(env, func(t *testing.T) { - keyring.MockInit() - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - project := RemoteProject{ID: "existing", Name: DefaultSharedProject, Type: "secret-manager", OrganizationID: "org", Environments: []RemoteEnvironment{{Slug: env}}} - cert := RemoteProject{ID: "cert", Name: "Certificates", Type: "cert-manager", OrganizationID: "org"} - foreign := project - foreign.ID, foreign.OrganizationID = "foreign", "other-org" - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch r.URL.Path { - case "/api/v1/workspace": - json.NewEncoder(w).Encode(map[string]any{"workspaces": []RemoteProject{cert, foreign, project}}) - case "/api/v1/workspace/existing": - json.NewEncoder(w).Encode(map[string]any{"workspace": project}) - case "/api/v1/workspace/cert": - json.NewEncoder(w).Encode(map[string]any{"workspace": cert}) - default: - t.Errorf("must not create or read secrets: %s %s", r.Method, r.URL) - http.NotFound(w, r) - } - })) - defer upstream.Close() - sharedTestSession(t, upstream.URL, "user") - ctx := context.Background() - projects, err := Projects(ctx) - if err != nil || len(projects) != 1 || projects[0].ID != "existing" { - t.Fatalf("wrong project filter: %+v %v", projects, err) - } - if _, err := BindGlobal(ctx, "cert", "dev"); err == nil { - t.Fatal("bound certificate project") - } - location, err := EnsureDefaultGlobal(ctx) - if env == "dev" { - if err != nil || location.ProjectID != "existing" { - t.Fatalf("did not reuse project: %+v %v", location, err) - } - } else { - if err == nil { - t.Fatal("silently switched default environment") - } - if location, _ = LoadGlobalLocation(); location != nil { - t.Fatal("saved incomplete setup") - } - } - }) - } -} - -func TestCreateSharedProjectValidatesAndDoesNotBind(t *testing.T) { - keyring.MockInit() - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - creates := 0 - forbidden := false - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch r.URL.Path { - case "/api/v2/workspace": - creates++ - if forbidden { - http.Error(w, "private-upstream-information", 403) - return - } - var body map[string]string - json.NewDecoder(r.Body).Decode(&body) - if body["projectName"] != "Team" || body["type"] != "secret-manager" { - t.Errorf("invalid body %v", body) - } - w.Write([]byte(`{"project":{"id":"new","name":"Team"}}`)) - case "/api/v1/workspace/new": - w.Write([]byte(`{"workspace":{"id":"new","name":"Team","type":"secret-manager","orgId":"org","environments":[{"slug":"dev"}]}}`)) - default: - t.Errorf("unexpected request: %s", r.URL) - http.NotFound(w, r) - } - })) - defer upstream.Close() - sharedTestSession(t, upstream.URL, "user") - for _, name := range []string{"", " ", "a\nb", strings.Repeat("a", 65)} { - if _, err := CreateRemoteProject(context.Background(), name); err == nil { - t.Errorf("accepted invalid name %q", name) - } - } - if creates != 0 { - t.Fatal("invalid name reached upstream") - } - project, err := CreateRemoteProject(context.Background(), " Team ") - if err != nil || project.ID != "new" || len(project.Environments) != 1 { - t.Fatalf("invalid created project: %+v %v", project, err) - } - if location, _ := LoadGlobalLocation(); location != nil { - t.Fatal("creating a project changed saved location") - } - forbidden = true - if _, err := CreateRemoteProject(context.Background(), "Team"); err == nil || strings.Contains(err.Error(), "private-upstream-information") { - t.Fatalf("permission failure mishandled: %v", err) - } -} diff --git a/packages/cli/internal/adapters/runtime/mise/download_test.go b/packages/cli/internal/adapters/runtime/mise/download_test.go deleted file mode 100644 index c9a6e436..00000000 --- a/packages/cli/internal/adapters/runtime/mise/download_test.go +++ /dev/null @@ -1,165 +0,0 @@ -package mise - -import ( - "context" - "crypto/sha256" - "errors" - "fmt" - "net/http" - "net/http/httptest" - "os" - "strings" - "sync/atomic" - "testing" - "time" -) - -func serveArchive(t *testing.T, archive []byte) (downloader, *httptest.Server, *atomic.Int32) { - t.Helper() - requests := new(atomic.Int32) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requests.Add(1) - _, _ = w.Write(archive) - })) - t.Cleanup(server.Close) - return downloader{client: server.Client(), baseURL: server.URL, backoff: time.Millisecond}, server, requests -} - -func TestDownloadRetriesOnlyTransientFailures(t *testing.T) { - for _, status := range []int{http.StatusRequestTimeout, http.StatusTooManyRequests, http.StatusBadGateway, http.StatusGatewayTimeout, http.StatusOK, http.StatusNotFound, http.StatusForbidden} { - t.Run(fmt.Sprint(status), func(t *testing.T) { - t.Parallel() - archive := []byte("verified archive") - a := releaseAsset{Platform: "test", Format: "zip", ArchiveSHA256: fmt.Sprintf("%x", sha256.Sum256(archive))} - var requests atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/"+a.Filename() || r.Header.Get("User-Agent") != "one-cli/mise-bootstrap" { - t.Errorf("unexpected download request: %s %s", r.URL.Path, r.Header.Get("User-Agent")) - } - if requests.Add(1) == 1 { - if status == http.StatusOK { - w.Header().Set("Content-Length", "100") // Interrupted response. - } - w.WriteHeader(status) - _, _ = w.Write([]byte("partial")) - return - } - _, _ = w.Write(archive) - })) - defer server.Close() - d := downloader{client: server.Client(), baseURL: server.URL, backoff: time.Millisecond} - dir := t.TempDir() - path, err := d.fetch(context.Background(), dir, a) - if status == http.StatusNotFound || status == http.StatusForbidden { - if err == nil || requests.Load() != 1 || !strings.Contains(err.Error(), fmt.Sprint(status)) { - t.Fatalf("permanent failure: requests=%d err=%v", requests.Load(), err) - } - } else { - if err != nil || requests.Load() != 2 { - t.Fatalf("retry: requests=%d err=%v", requests.Load(), err) - } - if err := verifyFile(path, a.ArchiveSHA256, maxArchiveSize); err != nil { - t.Fatal(err) - } - if err := os.Remove(path); err != nil { - t.Fatal(err) - } - } - assertNoTemporaryFiles(t, dir) - }) - } -} - -func TestDownloadRejectsDigestAndOversizedResponseWithoutRetry(t *testing.T) { - for _, oversized := range []bool{false, true} { - t.Run(fmt.Sprint(oversized), func(t *testing.T) { - var requests atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requests.Add(1) - if oversized { - w.Header().Set("Content-Length", fmt.Sprint(maxArchiveSize+1)) - } - _, _ = w.Write([]byte("bad digest")) - })) - defer server.Close() - d := downloader{client: server.Client(), baseURL: server.URL} - dir := t.TempDir() - _, err := d.fetch(context.Background(), dir, releaseAsset{Format: "zip"}) - if err == nil || requests.Load() != 1 { - t.Fatalf("unverified response: requests=%d err=%v", requests.Load(), err) - } - assertNoTemporaryFiles(t, dir) - }) - } -} - -func TestDownloadRetryLimitAndCancellation(t *testing.T) { - for _, cancelWait := range []bool{false, true} { - t.Run(fmt.Sprint(cancelWait), func(t *testing.T) { - var requests atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requests.Add(1) - w.WriteHeader(http.StatusServiceUnavailable) - })) - defer server.Close() - d := downloader{client: server.Client(), baseURL: server.URL, backoff: time.Millisecond} - ctx := context.Background() - if cancelWait { - d.backoff = time.Second - var cancel context.CancelFunc - ctx, cancel = context.WithTimeout(ctx, 100*time.Millisecond) - defer cancel() - } - dir := t.TempDir() - _, err := d.fetch(ctx, dir, releaseAsset{Format: "zip"}) - if cancelWait { - if !errors.Is(err, context.DeadlineExceeded) || requests.Load() != 1 { - t.Fatalf("retry cancellation: %d %v", requests.Load(), err) - } - } else if err == nil || requests.Load() != maxDownloadAttempts { - t.Fatalf("retry limit: %d %v", requests.Load(), err) - } - assertNoTemporaryFiles(t, dir) - }) - } -} - -func TestDownloadCancelsInFlightResponse(t *testing.T) { - started := make(chan struct{}) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("partial")) - w.(http.Flusher).Flush() - close(started) - <-r.Context().Done() - })) - defer server.Close() - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - go func() { <-started; cancel() }() - dir := t.TempDir() - d := downloader{client: server.Client(), baseURL: server.URL} - if _, err := d.fetch(ctx, dir, releaseAsset{Format: "zip"}); !errors.Is(err, context.Canceled) { - t.Fatalf("in-flight cancellation: %v", err) - } - assertNoTemporaryFiles(t, dir) -} - -func TestOfficialDownloadRejectsHTTPSDowngrade(t *testing.T) { - var targetRequests atomic.Int32 - target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { targetRequests.Add(1) })) - defer target.Close() - server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, target.URL, http.StatusFound) - })) - defer server.Close() - d := defaultDownloader() - d.client.Transport = server.Client().Transport - d.baseURL, d.backoff = server.URL, 0 - if _, err := d.fetch(context.Background(), t.TempDir(), releaseAsset{Format: "zip"}); err == nil || !strings.Contains(err.Error(), "non-HTTPS") { - t.Fatalf("downgrade: %v", err) - } - if targetRequests.Load() != 0 { - t.Fatal("followed insecure redirect") - } -} diff --git a/packages/cli/internal/adapters/runtime/mise/install_test.go b/packages/cli/internal/adapters/runtime/mise/install_test.go deleted file mode 100644 index 57fd0401..00000000 --- a/packages/cli/internal/adapters/runtime/mise/install_test.go +++ /dev/null @@ -1,293 +0,0 @@ -package mise - -import ( - "archive/tar" - "archive/zip" - "bytes" - "compress/gzip" - "context" - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "strings" - "sync" - "testing" - "time" - - "github.com/gofrs/flock" -) - -func archiveFixture(t *testing.T, format string, binary []byte) (releaseAsset, []byte) { - t.Helper() - var buf bytes.Buffer - a := releaseAsset{Platform: "test-platform", Format: format, BinarySHA256: fmt.Sprintf("%x", sha256.Sum256(binary))} - if format == "zip" { - z := zip.NewWriter(&buf) - w, err := z.Create("mise/bin/" + a.BinaryName()) - if err != nil { - t.Fatal(err) - } - if _, err := w.Write(binary); err != nil { - t.Fatal(err) - } - if err := z.Close(); err != nil { - t.Fatal(err) - } - } else { - gz := gzip.NewWriter(&buf) - tr := tar.NewWriter(gz) - // This unrelated entry must never be extracted to the filesystem. - if err := tr.WriteHeader(&tar.Header{Name: "../../escape", Mode: 0o644, Size: 1}); err != nil { - t.Fatal(err) - } - if _, err := tr.Write([]byte("x")); err != nil { - t.Fatal(err) - } - if err := tr.WriteHeader(&tar.Header{Name: "mise/bin/" + a.BinaryName(), Mode: 0o755, Size: int64(len(binary))}); err != nil { - t.Fatal(err) - } - if _, err := tr.Write(binary); err != nil { - t.Fatal(err) - } - if err := tr.Close(); err != nil { - t.Fatal(err) - } - if err := gz.Close(); err != nil { - t.Fatal(err) - } - } - a.ArchiveSHA256 = fmt.Sprintf("%x", sha256.Sum256(buf.Bytes())) - return a, buf.Bytes() -} - -func TestReleaseAssetsCoverSupportedOnePlatforms(t *testing.T) { - for _, target := range [][2]string{{"linux", "amd64"}, {"linux", "arm64"}, {"darwin", "amd64"}, {"darwin", "arm64"}, {"windows", "amd64"}} { - a, err := assetFor(target[0], target[1]) - if err != nil { - t.Fatal(err) - } - for _, digest := range []string{a.BinarySHA256, a.ArchiveSHA256} { - b, err := hex.DecodeString(digest) - if err != nil || len(b) != sha256.Size { - t.Fatalf("bad digest for %v", target) - } - } - if !strings.Contains(a.Filename(), managedVersion) { - t.Fatal("unversioned asset") - } - } - if _, err := assetFor("plan9", "amd64"); err == nil { - t.Fatal("unsupported platform accepted") - } -} - -func TestManagedInstallConcurrentOfflineReuseAndRepair(t *testing.T) { - for _, format := range []string{"tar.gz", "zip"} { - t.Run(format, func(t *testing.T) { - a, archive := archiveFixture(t, format, []byte("verified executable fixture")) - d, server, requests := serveArchive(t, archive) - root := t.TempDir() - i := installer{root: root, downloader: d} - var wg sync.WaitGroup - for range 8 { - wg.Add(1) - go func() { - defer wg.Done() - if _, err := i.ensure(context.Background(), a); err != nil { - t.Error(err) - } - }() - } - wg.Wait() - if requests.Load() != 1 { - t.Fatalf("downloaded %d times", requests.Load()) - } - path, err := i.ensure(context.Background(), a) - if err != nil { - t.Fatal(err) - } - if _, err := os.Stat(filepath.Join(root, "escape")); !os.IsNotExist(err) { - t.Fatal("archive escaped destination") - } - if err := os.WriteFile(path, []byte("damaged"), 0o755); err != nil { - t.Fatal(err) - } - if _, err := i.ensure(context.Background(), a); err != nil { - t.Fatal(err) - } - if err := os.Remove(path); err != nil { - t.Fatal(err) - } - if _, err := i.ensure(context.Background(), a); err != nil { - t.Fatal(err) - } - if requests.Load() != 3 { - t.Fatalf("repair requests = %d, want 3", requests.Load()) - } - server.Close() - if _, err := i.ensure(context.Background(), a); err != nil { - t.Fatalf("offline cache: %v", err) - } - assertNoTemporaryFiles(t, filepath.Dir(path)) - }) - } -} - -func TestManagedInstallFailureNeverPublishesOrReplacesBinary(t *testing.T) { - for _, failure := range []string{"archive-digest", "binary-digest", "cancelled", "invalid-archive"} { - t.Run(failure, func(t *testing.T) { - a, archive := archiveFixture(t, "tar.gz", []byte("expected binary")) - if failure == "archive-digest" { - a.ArchiveSHA256 = strings.Repeat("0", 64) - } - if failure == "binary-digest" { - a.BinarySHA256 = strings.Repeat("0", 64) - } - if failure == "invalid-archive" { - archive = []byte("not a tarball") - a.ArchiveSHA256 = fmt.Sprintf("%x", sha256.Sum256(archive)) - } - d, _, requests := serveArchive(t, archive) - i := installer{root: t.TempDir(), downloader: d} - dir := filepath.Join(i.root, managedVersion, a.Platform) - if err := os.MkdirAll(dir, 0o700); err != nil { - t.Fatal(err) - } - path := filepath.Join(dir, a.BinaryName()) - if err := os.WriteFile(path, []byte("previous file"), 0o755); err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - if failure == "cancelled" { - cancel() - } - if _, err := i.ensure(ctx, a); err == nil { - t.Fatal("bad install succeeded") - } - raw, err := os.ReadFile(path) - if err != nil || string(raw) != "previous file" { - t.Fatalf("previous binary modified: %v %q", err, raw) - } - if failure == "cancelled" && requests.Load() != 0 { - t.Fatal("cancelled install downloaded") - } - assertNoTemporaryFiles(t, dir) - }) - } -} - -func TestManagedInstallMigratesOnlyVerifiedLegacyBinary(t *testing.T) { - for _, valid := range []bool{true, false} { - t.Run(fmt.Sprint(valid), func(t *testing.T) { - binary := []byte("verified legacy binary") - a, archive := archiveFixture(t, "tar.gz", binary) - d, server, requests := serveArchive(t, archive) - i := installer{root: filepath.Join(t.TempDir(), "new"), legacyRoot: filepath.Join(t.TempDir(), "old"), downloader: d} - legacy := filepath.Join(i.legacyRoot, managedVersion, a.Platform, a.BinaryName()) - if err := os.MkdirAll(filepath.Dir(legacy), 0o700); err != nil { - t.Fatal(err) - } - old := binary - if !valid { - old = []byte("corrupt legacy") - } else { - server.Close() - } - if err := os.WriteFile(legacy, old, 0o755); err != nil { - t.Fatal(err) - } - target, err := i.ensure(context.Background(), a) - if err != nil { - t.Fatal(err) - } - if err := verifyExecutable(target, a.BinarySHA256); err != nil { - t.Fatal(err) - } - preserved, err := os.ReadFile(legacy) - if err != nil || !bytes.Equal(preserved, old) { - t.Fatal("legacy cache changed") - } - want := int32(1) - if valid { - want = 0 - } - if requests.Load() != want { - t.Fatalf("requests = %d, want %d", requests.Load(), want) - } - }) - } -} - -func TestManagedInstallCancellationWhileWaitingForLock(t *testing.T) { - a, archive := archiveFixture(t, "tar.gz", []byte("binary")) - d, _, requests := serveArchive(t, archive) - i := installer{root: t.TempDir(), downloader: d} - dir := filepath.Join(i.root, managedVersion, a.Platform) - if err := os.MkdirAll(dir, 0o700); err != nil { - t.Fatal(err) - } - lock := flock.New(filepath.Join(dir, ".install.lock")) - if err := lock.Lock(); err != nil { - t.Fatal(err) - } - defer lock.Unlock() - ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) - defer cancel() - if _, err := i.ensure(ctx, a); !errors.Is(err, context.DeadlineExceeded) { - t.Fatalf("lock cancellation: %v", err) - } - if requests.Load() != 0 { - t.Fatal("downloaded before obtaining lock") - } -} - -func TestPublishFailureAndCancellationPreserveDestination(t *testing.T) { - for _, cancelWrite := range []bool{false, true} { - t.Run(fmt.Sprint(cancelWrite), func(t *testing.T) { - path := filepath.Join(t.TempDir(), "mise") - if err := os.WriteFile(path, []byte("previous"), 0o755); err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - data := []byte("new") - digest := fmt.Sprintf("%x", sha256.Sum256(data)) - err := publishBinary(ctx, path, digest, func(w io.Writer) error { - if _, err := w.Write(data); err != nil { - return err - } - if cancelWrite { - cancel() - return nil - } - return errors.New("local write failed") - }) - if err == nil { - t.Fatal("failed write published") - } - got, err := os.ReadFile(path) - if err != nil || string(got) != "previous" { - t.Fatalf("destination: %q %v", got, err) - } - assertNoTemporaryFiles(t, filepath.Dir(path)) - }) - } -} - -func assertNoTemporaryFiles(t *testing.T, dir string) { - t.Helper() - entries, err := os.ReadDir(dir) - if err != nil { - t.Fatal(err) - } - for _, entry := range entries { - if strings.HasPrefix(entry.Name(), ".extract-") || strings.HasPrefix(entry.Name(), ".fetch-") { - t.Errorf("temporary file leaked: %s", entry.Name()) - } - } -} diff --git a/packages/cli/internal/adapters/runtime/mise/mise_test.go b/packages/cli/internal/adapters/runtime/mise/mise_test.go deleted file mode 100644 index 197e0d46..00000000 --- a/packages/cli/internal/adapters/runtime/mise/mise_test.go +++ /dev/null @@ -1,258 +0,0 @@ -package mise - -import ( - "context" - "crypto/sha256" - "errors" - "fmt" - "os" - "path/filepath" - "runtime" - "strings" - "testing" - - runtimeport "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/runtime" -) - -// Copies of this test executable act as portable PATH mise fixtures, including -// on Windows where shell scripts cannot be launched as native executables. -func TestMain(m *testing.M) { - if len(os.Args) == 2 && os.Args[1] == "--version" { - path, err := os.Executable() - if err == nil { - if version, err := os.ReadFile(path + ".version"); err == nil { - fmt.Print(string(version)) - os.Exit(0) - } - } - os.Exit(1) - } - os.Exit(m.Run()) -} - -func isolatedRuntimeHome(t *testing.T) string { - t.Helper() - home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("ONE_MISE_BINARY", "") - for _, key := range []string{"XDG_DATA_HOME", "XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME"} { - t.Setenv(key, filepath.Join(home, key)) - } - return home -} - -func fakeMise(t *testing.T, dir, version string) string { - t.Helper() - name := "mise" - if runtime.GOOS == "windows" { - name += ".exe" - } - path := filepath.Join(dir, name) - exe, err := os.Executable() - if err != nil { - t.Fatal(err) - } - data, err := os.ReadFile(exe) - if err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(dir, 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, data, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path+".version", []byte(version), 0o600); err != nil { - t.Fatal(err) - } - return path -} - -func TestSystemMiseUsesCommandPathWithoutInstalling(t *testing.T) { - home := isolatedRuntimeHome(t) - dir := filepath.Join(t.TempDir(), "system mise") - want := fakeMise(t, dir, "2026.9.7") - command := runtimeport.Command{Directory: home, Argv: []string{"--version"}, Env: []string{"PATH=" + dir, "KEEP=yes"}} - got, err := (Provider{}).PrepareCLI(context.Background(), command) - if err != nil { - t.Fatal(err) - } - if got.Argv[0] != want { - t.Fatalf("selected %s, want system %s", got.Argv[0], want) - } - entries, err := os.ReadDir(home) - if err != nil || len(entries) != 0 { - t.Fatalf("system resolution wrote managed files: %v %v", entries, err) - } -} - -func TestSupportedVersion(t *testing.T) { - for _, tt := range []struct { - version string - want bool - }{ - {"2026.9.7 linux-x64 (2026-09-13)", true}, {"2026.10.0", true}, {"2027.1.0", true}, - {"2026.9.6", false}, {"2026.8.999", false}, {"", false}, {"2026.9.7-dev", false}, {"2026.9.x", false}, - } { - if got := supportedVersion(tt.version); got != tt.want { - t.Errorf("%q: got %v want %v", tt.version, got, tt.want) - } - } -} - -func TestExplicitBinaryIsRespectedAndVersionChecked(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("POSIX fake executable") - } - dir := t.TempDir() - path := filepath.Join(dir, "private mise") - if err := os.WriteFile(path, []byte("#!/bin/sh\necho 2026.9.7\n"), 0o755); err != nil { - t.Fatal(err) - } - t.Setenv("ONE_MISE_BINARY", path) - command := runtimeport.Command{Directory: dir, Argv: []string{"node", "script.mjs", "a b", ""}, Env: []string{"PATH=/somewhere", "KEEP=unchanged"}} - prepared, err := (Provider{}).Prepare(context.Background(), command) - if err != nil { - t.Fatal(err) - } - if prepared.Argv[0] != path || prepared.Argv[len(prepared.Argv)-1] != "" { - t.Fatalf("argv: %q", prepared.Argv) - } - if !strings.HasPrefix(envValue(prepared.Env, "PATH"), dir+string(os.PathListSeparator)) { - t.Fatalf("private runtime path missing: %q", prepared.Env) - } - if command.Env[0] != "PATH=/somewhere" { - t.Fatal("mutated caller environment") - } - if err := os.WriteFile(path, []byte("#!/bin/sh\necho 2025.1.0\n"), 0o755); err != nil { - t.Fatal(err) - } - if _, err := (Provider{}).Prepare(context.Background(), command); err == nil { - t.Fatal("unsupported explicit version accepted") - } -} - -func TestResolverRechecksSystemDeletionAndManagedRepair(t *testing.T) { - home := isolatedRuntimeHome(t) - paths, err := defaultPaths() - if err != nil { - t.Fatal(err) - } - a, archive := archiveFixture(t, "tar.gz", []byte("managed fixture")) - d, server, requests := serveArchive(t, archive) - i := installer{root: paths.runtimeRoot(), legacyRoot: paths.legacyRoot(), downloader: d} - r := binaryResolver{paths: paths, asset: func() (releaseAsset, error) { return a, nil }, install: i.ensure} - systemDir := filepath.Join(t.TempDir(), "system") - system := fakeMise(t, systemDir, "2026.9.7") - command := runtimeport.Command{Directory: home, Env: []string{"PATH=" + systemDir}} - resolve := func(source string, downloads int32) resolvedBinary { - t.Helper() - got, err := r.resolve(context.Background(), command) - if err != nil || got.source != source || requests.Load() != downloads { - t.Fatalf("resolve: %+v err=%v requests=%d; want %s/%d", got, err, requests.Load(), source, downloads) - } - return got - } - resolve("system", 0) - if err := os.Remove(system); err != nil { - t.Fatal(err) - } - managed := resolve("managed", 1) - resolve("managed", 1) - if err := os.Remove(managed.path); err != nil { - t.Fatal(err) - } - resolve("managed", 2) - fakeMise(t, systemDir, "2026.10.0") - resolve("system", 2) - if err := os.WriteFile(system+".version", []byte("2025.1.0"), 0o600); err != nil { - t.Fatal(err) - } - resolve("managed", 2) - server.Close() - resolve("managed", 2) - if err := os.Remove(managed.path); err != nil { - t.Fatal(err) - } - if _, err := r.resolve(context.Background(), command); err == nil || !strings.Contains(err.Error(), "Could not prepare mise") { - t.Fatalf("offline empty install must fail: %v", err) - } -} - -func TestResolverExplicitFailureAndCancellationDoNotInstall(t *testing.T) { - isolatedRuntimeHome(t) - r := binaryResolver{install: func(context.Context, releaseAsset) (string, error) { - t.Fatal("unexpected installation") - return "", nil - }} - path := fakeMise(t, t.TempDir(), "2025.1.0") - t.Setenv("ONE_MISE_BINARY", path) - if _, err := r.resolve(context.Background(), runtimeport.Command{}); err == nil { - t.Fatal("unsupported explicit version accepted") - } - if err := os.Remove(path); err != nil { - t.Fatal(err) - } - if _, err := r.resolve(context.Background(), runtimeport.Command{}); err == nil { - t.Fatal("missing explicit version accepted") - } - t.Setenv("ONE_MISE_BINARY", "") - ctx, cancel := context.WithCancel(context.Background()) - cancel() - if _, err := r.resolve(ctx, runtimeport.Command{}); !errors.Is(err, context.Canceled) { - t.Fatalf("cancellation: %v", err) - } -} - -func TestResolverSkipsManagedPathAndAcceptsExternalWithoutManagedDirectories(t *testing.T) { - isolatedRuntimeHome(t) - paths, err := defaultPaths() - if err != nil { - t.Fatal(err) - } - managedDir := filepath.Join(paths.runtimeRoot(), managedVersion, "fixture") - managed := fakeMise(t, managedDir, "2026.9.7") - externalDir := t.TempDir() - external := fakeMise(t, externalDir, "2026.9.7") - r := binaryResolver{paths: paths, pathsErr: errors.New("managed directory unavailable")} - command := runtimeport.Command{Env: []string{"PATH=" + managedDir + string(os.PathListSeparator) + externalDir}} - got, err := r.resolve(context.Background(), command) - if err != nil || got.source != "system" || got.path != external { - t.Fatalf("discovery: %+v %v", got, err) - } - // A direct override inside One's tree must be verified BEFORE execution. - t.Setenv("ONE_MISE_BINARY", managed) - r.pathsErr = nil - r.asset = func() (releaseAsset, error) { return releaseAsset{BinarySHA256: strings.Repeat("0", 64)}, nil } - if _, err := r.resolve(context.Background(), command); err == nil { - t.Fatal("unverified managed override accepted") - } - raw, err := os.ReadFile(managed) - if err != nil { - t.Fatal(err) - } - r.asset = func() (releaseAsset, error) { - return releaseAsset{BinarySHA256: fmt.Sprintf("%x", sha256.Sum256(raw))}, nil - } - got, err = r.resolve(context.Background(), command) - if err != nil || !got.managed || got.source != "explicit" { - t.Fatalf("managed override: %+v %v", got, err) - } -} - -func TestExternalProviderPreservesMiseDirectories(t *testing.T) { - isolatedRuntimeHome(t) - dir := t.TempDir() - fakeMise(t, dir, "2026.9.7") - env := []string{"PATH=" + dir, "MISE_DATA_DIR=/external-data", "MISE_CONFIG_DIR=/external-config", "MISE_STATE_DIR=/external-state", "MISE_CACHE_DIR=/external-cache", "MISE_AUTO_UPDATE=true"} - got, err := (Provider{}).PrepareCLI(context.Background(), runtimeport.Command{Env: env}) - if err != nil { - t.Fatal(err) - } - for _, value := range env[1:] { - key, expected, _ := strings.Cut(value, "=") - if envValue(got.Env, key) != expected { - t.Errorf("external setting changed: %s", key) - } - } -} diff --git a/packages/cli/internal/adapters/runtime/mise/paths_test.go b/packages/cli/internal/adapters/runtime/mise/paths_test.go deleted file mode 100644 index fcac28c9..00000000 --- a/packages/cli/internal/adapters/runtime/mise/paths_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package mise - -import ( - "os" - "path/filepath" - "reflect" - "runtime" - "testing" -) - -func TestRuntimePathsDefaultsAndXDG(t *testing.T) { - home := isolatedRuntimeHome(t) - paths, err := defaultPaths() - if err != nil { - t.Fatal(err) - } - if paths.runtimeRoot() != filepath.Join(home, "XDG_DATA_HOME", "one", "runtimes", "mise") || paths.legacyRoot() != filepath.Join(home, "XDG_CACHE_HOME", "one", "runtimes", "mise") { - t.Fatalf("XDG paths: %+v", paths) - } - for _, key := range []string{"XDG_DATA_HOME", "XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME"} { - t.Setenv(key, "") - } - paths, err = defaultPaths() - want := runtimePaths{data: filepath.Join(home, ".local", "share", "one"), config: filepath.Join(home, ".config", "one"), state: filepath.Join(home, ".local", "state", "one"), cache: filepath.Join(home, ".cache", "one")} - if err != nil || paths != want { - t.Fatalf("default paths: %+v %v", paths, err) - } - for _, key := range []string{"XDG_DATA_HOME", "XDG_CONFIG_HOME", "XDG_STATE_HOME", "XDG_CACHE_HOME"} { - t.Run(key, func(t *testing.T) { - t.Setenv(key, "relative") - if _, err := defaultPaths(); err == nil { - t.Fatal("relative root accepted") - } - }) - } -} - -func TestManagedEnvironmentOwnsDirectoriesWithoutMutatingCaller(t *testing.T) { - isolatedRuntimeHome(t) - paths, err := defaultPaths() - if err != nil { - t.Fatal(err) - } - env := []string{"PATH=/existing", "KEEP=value", "MISE_DATA_DIR=/external", "MISE_DATA_DIR=/duplicate", "MISE_CONFIG_DIR=/external", "MISE_STATE_DIR=/external", "MISE_CACHE_DIR=/external", "MISE_AUTO_UPDATE=true", "MISE_DISABLE_UPDATE_WARNING=false"} - before := append([]string(nil), env...) - got := paths.environment(env) - for key, value := range map[string]string{ - "MISE_DATA_DIR": filepath.Join(paths.data, "mise"), "MISE_CONFIG_DIR": filepath.Join(paths.config, "mise"), - "MISE_STATE_DIR": filepath.Join(paths.state, "mise"), "MISE_CACHE_DIR": filepath.Join(paths.cache, "mise"), - "MISE_AUTO_UPDATE": "false", "MISE_DISABLE_UPDATE_WARNING": "true", "KEEP": "value", "PATH": "/existing", - } { - if envValue(got, key) != value { - t.Errorf("%s = %s, want %s", key, envValue(got, key), value) - } - } - if !reflect.DeepEqual(env, before) || len(got) != len(env)-1 { - t.Fatalf("environment mutation or duplicate: %q -> %q", env, got) - } - if withinPath(paths.cache, paths.runtimeRoot()) || withinPath(paths.cache, filepath.Join(paths.data, "mise")) || withinPath(paths.cache, filepath.Join(paths.state, "mise")) { - t.Fatal("cache cleanup can delete runtime, tools or trust") - } -} - -func TestRuntimeOwnershipThroughSymlinkAndMissingFile(t *testing.T) { - isolatedRuntimeHome(t) - paths, err := defaultPaths() - if err != nil { - t.Fatal(err) - } - root := paths.runtimeRoot() - if err := os.MkdirAll(root, 0o700); err != nil { - t.Fatal(err) - } - link := filepath.Join(t.TempDir(), "linked root") - if err := os.Symlink(root, link); err != nil { - if runtime.GOOS == "windows" { - t.Skipf("symlink privilege unavailable: %v", err) - } - t.Fatal(err) - } - if !paths.owns(filepath.Join(link, "missing", "mise")) || !paths.owns(filepath.Join(paths.legacyRoot(), "missing", "mise")) { - t.Fatal("managed missing/symlink path treated as external") - } - if paths.owns(root + "-external") { - t.Fatal("path prefix collision treated as owned") - } - outward := filepath.Join(root, "outside") - if err := os.Symlink(t.TempDir(), outward); err != nil { - t.Fatal(err) - } - if !paths.owns(filepath.Join(outward, "mise")) { - t.Fatal("symlink inside runtime root bypassed ownership") - } -} diff --git a/packages/cli/internal/adapters/workspaceregistry/local/store_test.go b/packages/cli/internal/adapters/workspaceregistry/local/store_test.go deleted file mode 100644 index 988035b6..00000000 --- a/packages/cli/internal/adapters/workspaceregistry/local/store_test.go +++ /dev/null @@ -1,328 +0,0 @@ -package local - -import ( - "context" - "errors" - "fmt" - "os" - "path/filepath" - "runtime" - "sync" - "testing" - "time" - - "github.com/gofrs/flock" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" -) - -func TestStoreRoundTripAndPermissions(t *testing.T) { - path := filepath.Join(t.TempDir(), "one", "workspaces.json") - store := NewAt(path) - now := time.Date(2026, time.August, 30, 10, 0, 0, 0, time.UTC) - want := workspace.RegistryEntry{ - EntryID: "wsr_roundtrip", - WorkspaceID: "demo-a1b2c3", - Name: "demo", - Root: filepath.Join(t.TempDir(), "demo"), - RegisteredAt: now, - LastSeenAt: now, - LastSeenBy: "create", - } - if err := store.Update(context.Background(), func(registry *workspace.Registry) error { - registry.Workspaces = append(registry.Workspaces, want) - return nil - }); err != nil { - t.Fatalf("Update: %v", err) - } - - got, err := store.Load(context.Background()) - if err != nil { - t.Fatalf("Load: %v", err) - } - if got.Version != workspace.RegistrySchemaVersion { - t.Fatalf("version = %d, want %d", got.Version, workspace.RegistrySchemaVersion) - } - if len(got.Workspaces) != 1 || got.Workspaces[0] != want { - t.Fatalf("round trip = %#v, want %#v", got.Workspaces, want) - } - if store.Path() != path { - t.Fatalf("Path() = %q, want %q", store.Path(), path) - } - - if runtime.GOOS != "windows" { - fileInfo, statErr := os.Stat(path) - if statErr != nil { - t.Fatal(statErr) - } - if mode := fileInfo.Mode().Perm(); mode != 0o600 { - t.Fatalf("registry mode = %o, want 600", mode) - } - dirInfo, statErr := os.Stat(filepath.Dir(path)) - if statErr != nil { - t.Fatal(statErr) - } - if mode := dirInfo.Mode().Perm(); mode != 0o700 { - t.Fatalf("registry directory mode = %o, want 700", mode) - } - } -} - -func TestPathHonorsXDGConfigHome(t *testing.T) { - xdg := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", xdg) - got, err := Path() - if err != nil { - t.Fatal(err) - } - want := filepath.Join(xdg, "one", "workspaces.json") - if got != want { - t.Fatalf("Path() = %q, want %q", got, want) - } -} - -func TestStoreRenameFailureLeavesPreviousFileUntouched(t *testing.T) { - path := filepath.Join(t.TempDir(), "one", "workspaces.json") - store := NewAt(path) - seedRegistryEntry(t, store, "wsr_original") - before, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - - store.renameFile = func(string, string) error { return errors.New("injected rename failure") } - err = store.Update(context.Background(), func(registry *workspace.Registry) error { - registry.Workspaces[0].Name = "changed" - return nil - }) - if err == nil { - t.Fatal("Update succeeded despite injected rename failure") - } - after, readErr := os.ReadFile(path) - if readErr != nil { - t.Fatal(readErr) - } - if string(after) != string(before) { - t.Fatalf("failed atomic publication changed destination\nbefore: %s\nafter: %s", before, after) - } - temps, globErr := filepath.Glob(filepath.Join(filepath.Dir(path), ".workspaces-*.json")) - if globErr != nil { - t.Fatal(globErr) - } - if len(temps) != 0 { - t.Fatalf("temporary files leaked: %v", temps) - } -} - -func TestStoreDamagedOrFutureRegistryIsNeverOverwritten(t *testing.T) { - tests := []struct { - name string - raw string - }{ - {name: "damaged", raw: `{"version":1,"workspaces":[`}, - {name: "future", raw: `{"version":2,"workspaces":[]}`}, - {name: "duplicate entry ids", raw: `{"version":1,"workspaces":[{"entryId":"same","root":"/a","registeredAt":"2026-08-30T00:00:00Z","lastSeenAt":"2026-08-30T00:00:00Z"},{"entryId":"same","root":"/b","registeredAt":"2026-08-30T00:00:00Z","lastSeenAt":"2026-08-30T00:00:00Z"}]}`}, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - path := filepath.Join(t.TempDir(), "one", "workspaces.json") - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte(test.raw), 0o600); err != nil { - t.Fatal(err) - } - mutated := false - err := NewAt(path).Update(context.Background(), func(*workspace.Registry) error { - mutated = true - return nil - }) - if err == nil { - t.Fatal("Update succeeded for unsafe registry") - } - if mutated { - t.Fatal("mutator ran before registry validation") - } - after, readErr := os.ReadFile(path) - if readErr != nil { - t.Fatal(readErr) - } - if string(after) != test.raw { - t.Fatalf("unsafe registry was overwritten: %s", after) - } - }) - } -} - -func TestStoreConcurrentUpdatesDoNotLoseEntries(t *testing.T) { - path := filepath.Join(t.TempDir(), "one", "workspaces.json") - const writers = 32 - start := make(chan struct{}) - errorsByWriter := make(chan error, writers) - var wait sync.WaitGroup - for index := 0; index < writers; index++ { - index := index - wait.Add(1) - go func() { - defer wait.Done() - <-start - store := NewAt(path) - err := store.Update(context.Background(), func(registry *workspace.Registry) error { - registry.Workspaces = append(registry.Workspaces, workspace.RegistryEntry{ - EntryID: fmt.Sprintf("wsr_%02d", index), - Root: fmt.Sprintf("/workspace/%02d", index), - RegisteredAt: time.Unix(int64(index), 0).UTC(), - LastSeenAt: time.Unix(int64(index), 0).UTC(), - }) - return nil - }) - errorsByWriter <- err - }() - } - close(start) - wait.Wait() - close(errorsByWriter) - for err := range errorsByWriter { - if err != nil { - t.Fatalf("concurrent Update: %v", err) - } - } - - registry, err := NewAt(path).Load(context.Background()) - if err != nil { - t.Fatal(err) - } - if len(registry.Workspaces) != writers { - t.Fatalf("entries = %d, want %d", len(registry.Workspaces), writers) - } - seen := make(map[string]bool, writers) - for _, entry := range registry.Workspaces { - seen[entry.EntryID] = true - } - for index := 0; index < writers; index++ { - entryID := fmt.Sprintf("wsr_%02d", index) - if !seen[entryID] { - t.Errorf("lost concurrent entry %s", entryID) - } - } -} - -func TestStoreHonorsShorterContextDeadlineWhileLocked(t *testing.T) { - path := filepath.Join(t.TempDir(), "one", "workspaces.json") - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - t.Fatal(err) - } - externalLock := flock.New(path + ".lock") - if err := externalLock.Lock(); err != nil { - t.Fatal(err) - } - defer externalLock.Unlock() - - ctx, cancel := context.WithTimeout(context.Background(), 40*time.Millisecond) - defer cancel() - started := time.Now() - err := NewAt(path).Update(ctx, func(*workspace.Registry) error { return nil }) - if !errors.Is(err, context.DeadlineExceeded) { - t.Fatalf("Update error = %v, want context deadline exceeded", err) - } - if elapsed := time.Since(started); elapsed > time.Second { - t.Fatalf("short caller deadline was not respected: %s", elapsed) - } -} - -func TestStoreContextDeadlineAlsoBoundsInProcessLock(t *testing.T) { - path := filepath.Join(t.TempDir(), "one", "workspaces.json") - store := NewAt(path) - release, err := store.acquire(context.Background(), true) - if err != nil { - t.Fatal(err) - } - defer release() - - ctx, cancel := context.WithTimeout(context.Background(), 40*time.Millisecond) - defer cancel() - started := time.Now() - err = store.Update(ctx, func(*workspace.Registry) error { return nil }) - if !errors.Is(err, context.DeadlineExceeded) { - t.Fatalf("Update error = %v, want context deadline exceeded", err) - } - if elapsed := time.Since(started); elapsed > time.Second { - t.Fatalf("in-process lock ignored caller deadline: %s", elapsed) - } -} - -func TestStoreBackgroundContextWaitsPastShortLockContention(t *testing.T) { - path := filepath.Join(t.TempDir(), "one", "workspaces.json") - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - t.Fatal(err) - } - externalLock := flock.New(path + ".lock") - if err := externalLock.Lock(); err != nil { - t.Fatal(err) - } - const hold = 2200 * time.Millisecond - timer := time.AfterFunc(hold, func() { - _ = externalLock.Unlock() - }) - defer func() { - timer.Stop() - _ = externalLock.Unlock() - }() - - started := time.Now() - err := NewAt(path).Update(context.Background(), func(registry *workspace.Registry) error { - registry.Workspaces = append(registry.Workspaces, workspace.RegistryEntry{ - EntryID: "wsr_after_contention", - Root: "/workspace/after-contention", - RegisteredAt: time.Unix(1, 0).UTC(), - LastSeenAt: time.Unix(1, 0).UTC(), - }) - return nil - }) - if err != nil { - t.Fatalf("Update after short contention: %v", err) - } - if elapsed := time.Since(started); elapsed < hold { - t.Fatalf("Update returned before contention ended: %s < %s", elapsed, hold) - } -} - -func TestStoreMutatorErrorDoesNotPublish(t *testing.T) { - path := filepath.Join(t.TempDir(), "one", "workspaces.json") - store := NewAt(path) - seedRegistryEntry(t, store, "wsr_original") - before, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - wantErr := errors.New("stop mutation") - err = store.Update(context.Background(), func(registry *workspace.Registry) error { - registry.Workspaces = nil - return wantErr - }) - if !errors.Is(err, wantErr) { - t.Fatalf("Update error = %v, want %v", err, wantErr) - } - after, readErr := os.ReadFile(path) - if readErr != nil { - t.Fatal(readErr) - } - if string(after) != string(before) { - t.Fatal("mutator error changed registry file") - } -} - -func seedRegistryEntry(t *testing.T, store *Store, entryID string) { - t.Helper() - now := time.Date(2026, time.August, 30, 10, 0, 0, 0, time.UTC) - if err := store.Update(context.Background(), func(registry *workspace.Registry) error { - registry.Workspaces = append(registry.Workspaces, workspace.RegistryEntry{ - EntryID: entryID, - Root: filepath.Join(t.TempDir(), entryID), - RegisteredAt: now, - LastSeenAt: now, - }) - return nil - }); err != nil { - t.Fatal(err) - } -} diff --git a/packages/cli/internal/application/execution/runtime_test.go b/packages/cli/internal/application/execution/runtime_test.go deleted file mode 100644 index 5564cf6b..00000000 --- a/packages/cli/internal/application/execution/runtime_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package execution - -import ( - "os" - "path/filepath" - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" -) - -func TestRuntimeSelectionPreservesLegacyAndUsesGeneratedConfig(t *testing.T) { - t.Setenv("ONE_RUNTIME", "") - root := t.TempDir() - if got, err := RuntimeKind(root); err != nil || got != "builtin" { - t.Fatalf("legacy: %q %v", got, err) - } - path := filepath.Join(root, workspace.MiseConfigFilename) - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte("min_version = '2026.9.7'\n"), 0o644); err != nil { - t.Fatal(err) - } - if got, err := RuntimeKind(root); err != nil || got != "mise" { - t.Fatalf("configured: %q %v", got, err) - } - t.Setenv("ONE_RUNTIME", "builtin") - if got, err := RuntimeKind(root); err != nil || got != "builtin" { - t.Fatalf("override: %q %v", got, err) - } - t.Setenv("ONE_RUNTIME", "typo") - if _, err := RuntimeKind(root); err == nil { - t.Fatal("invalid runtime was ignored") - } -} diff --git a/packages/cli/internal/application/execution/scope_test.go b/packages/cli/internal/application/execution/scope_test.go deleted file mode 100644 index d3576f58..00000000 --- a/packages/cli/internal/application/execution/scope_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package execution - -import ( - "context" - "testing" - - catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" -) - -func TestExecutionScopeDeriveIsImmutable(t *testing.T) { - t.Parallel() - - parent := NewScope(context.Background(), "/work") - child := parent.Derive(ScopePatch{ - WorkspaceRoot: "/work/demo", - Environment: "production", - Backend: catalog.BackendID{Domain: catalog.DomainEnv, Name: "infisical"}, - }) - if parent.WorkspaceRoot() != "" || parent.Environment() != "" || parent.Backend().String() != "" { - t.Fatalf("parent mutated: %#v", parent) - } - if child.WorkspaceRoot() != "/work/demo" || child.Environment() != "production" || - child.Backend().String() != "env/infisical" { - t.Fatalf("child = %#v", child) - } -} diff --git a/packages/cli/internal/application/execution/workspace_test.go b/packages/cli/internal/application/execution/workspace_test.go deleted file mode 100644 index afd47d37..00000000 --- a/packages/cli/internal/application/execution/workspace_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package execution - -import ( - "context" - "os" - "path/filepath" - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" -) - -func TestResolveWorkspaceScopeCreatesReusableSnapshot(t *testing.T) { - root := t.TempDir() - projectDir := filepath.Join(root, "apps", "web") - workingDirectory := filepath.Join(projectDir, "src") - if err := os.MkdirAll(workingDirectory, 0o755); err != nil { - t.Fatal(err) - } - if err := workspace.WriteManifest(root, &workspace.Manifest{ - Workspace: &workspace.ManifestWorkspace{ID: "ws-demo", Name: "demo"}, - Projects: []workspace.ManifestProject{{ - Name: "web", RelativeDir: "apps/web", Toolchain: "node", - }}, - }); err != nil { - t.Fatal(err) - } - parent := NewScope(context.Background(), workingDirectory) - resolved, err := ResolveWorkspaceScope(parent) - if err != nil { - t.Fatal(err) - } - if resolved.Root() != root || resolved.Scope().WorkspaceRoot() != root { - t.Fatalf("resolved root = %q, scope = %#v", resolved.Root(), resolved.Scope()) - } - if parent.WorkspaceRoot() != "" { - t.Fatal("workspace resolution mutated the parent scope") - } - byName, ok := resolved.Project("web") - if !ok || byName.TargetDir != projectDir { - t.Fatalf("Project(web) = %#v, %v", byName, ok) - } - byPath, ok := resolved.Project("./apps/web/") - if !ok || byPath.Name != "web" { - t.Fatalf("Project(path) = %#v, %v", byPath, ok) - } - fromCWD, ok := resolved.ProjectFromWorkingDirectory() - if !ok || fromCWD.Name != "web" { - t.Fatalf("ProjectFromWorkingDirectory() = %#v, %v", fromCWD, ok) - } -} - -func TestWorkspaceReloadRefreshesManifest(t *testing.T) { - root := t.TempDir() - if err := workspace.WriteManifest(root, &workspace.Manifest{ - Workspace: &workspace.ManifestWorkspace{ID: "ws-demo", Name: "demo"}, - }); err != nil { - t.Fatal(err) - } - resolved, err := ResolveWorkspaceScope(NewScope(context.Background(), root)) - if err != nil { - t.Fatal(err) - } - if err := workspace.WriteManifest(root, &workspace.Manifest{ - Workspace: &workspace.ManifestWorkspace{ID: "ws-demo", Name: "demo"}, - Projects: []workspace.ManifestProject{{Name: "api", RelativeDir: "services/api", Toolchain: "node"}}, - }); err != nil { - t.Fatal(err) - } - if len(resolved.Projects()) != 0 { - t.Fatal("snapshot changed before Reload") - } - resolved, err = resolved.Reload() - if err != nil || len(resolved.Projects()) != 1 || resolved.Projects()[0].Name != "api" { - t.Fatalf("Reload() = %#v, %v", resolved.Projects(), err) - } -} - -func TestResolveWorkspaceScopeRequiresManifest(t *testing.T) { - _, err := ResolveWorkspaceScope(NewScope(context.Background(), t.TempDir())) - if code := codedError(err); code != "NOT_ONE_PROJECT" { - t.Fatalf("error = %v, code = %q", err, code) - } -} - -func codedError(err error) string { - if coded, ok := err.(interface{ ErrorCode() string }); ok { - return coded.ErrorCode() - } - return "" -} diff --git a/packages/cli/internal/application/manifest/service_test.go b/packages/cli/internal/application/manifest/service_test.go deleted file mode 100644 index 22709d1e..00000000 --- a/packages/cli/internal/application/manifest/service_test.go +++ /dev/null @@ -1,115 +0,0 @@ -package manifest - -import ( - "bytes" - "context" - "errors" - "os" - "strings" - "testing" - - catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" - workspacecore "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" -) - -func seedManifest(t *testing.T) (string, *Service, string) { - t.Helper() - root := t.TempDir() - raw := []byte("# Team settings\nversion=2\n[workspace]\nid='demo'\nname='Demo'\n[projects.web]\npath='apps/web'\ntoolchain='node'\n[env.infisical]\nprojectId='old' # binding\nenvironments=['dev','staging','prod']\n") - if err := os.WriteFile(workspacecore.ManifestPath(root), raw, 0600); err != nil { - t.Fatal(err) - } - s, err := NewService(catalog.Builtin()) - if err != nil { - t.Fatal(err) - } - _, rev, err := workspacecore.ReadManifestSnapshot(root) - if err != nil { - t.Fatal(err) - } - return root, s, rev -} -func binding(id string) *WorkspaceManifestPatch { - return &WorkspaceManifestPatch{Environment: &WorkspaceEnvironmentPatch{Backend: "infisical", ProjectID: &id}} -} -func TestPreviewMatchesPublishedTOMLAndPreservesUserText(t *testing.T) { - root, s, rev := seedManifest(t) - ctx := context.Background() - path := workspacecore.ManifestPath(root) - original, _ := os.ReadFile(path) - preview, err := s.PreviewManifestDraft(ctx, root, PreviewManifestInput{Revision: rev, Workspace: binding("new")}) - if err != nil { - t.Fatal(err) - } - afterPreview, _ := os.ReadFile(path) - if preview.Before != string(original) || !bytes.Equal(original, afterPreview) { - t.Fatal("preview changed source") - } - if !strings.Contains(preview.After, "# Team settings") || !strings.Contains(preview.After, "# binding") || !strings.Contains(preview.After, "path='apps/web'") { - t.Fatal(preview.After) - } - m, err := workspacecore.ParseManifest([]byte(preview.After)) - if err != nil || m.Env.ProjectID != "new" { - t.Fatalf("invalid preview: %+v %v", m, err) - } - result, err := s.ApplyManifestDraft(ctx, root, ApplyManifestInput{Revision: rev, Workspace: binding("new")}) - if err != nil { - t.Fatal(err) - } - published, _ := os.ReadFile(path) - if string(published) != preview.After || result.Revision == rev || result.Applied != 1 { - t.Fatalf("preview differs from publication: %+v", result) - } -} -func TestStaleDraftAndPreviewDoNotWrite(t *testing.T) { - root, s, rev := seedManifest(t) - path := workspacecore.ManifestPath(root) - original, _ := os.ReadFile(path) - for _, preview := range []bool{false, true} { - var err error - if preview { - _, err = s.PreviewManifestDraft(context.Background(), root, PreviewManifestInput{Revision: rev + "stale", Workspace: binding("new")}) - } else { - _, err = s.ApplyManifestDraft(context.Background(), root, ApplyManifestInput{Revision: rev + "stale", Workspace: binding("new")}) - } - if !errors.Is(err, ErrManifestConflict) { - t.Fatal(err) - } - after, _ := os.ReadFile(path) - if !bytes.Equal(original, after) { - t.Fatal("stale edit changed source") - } - } -} -func TestInvalidProjectEditDoesNotPartiallyPublishBinding(t *testing.T) { - root, s, rev := seedManifest(t) - path := workspacecore.ManifestPath(root) - original, _ := os.ReadFile(path) - for _, name := range []string{"web", "missing"} { - _, err := s.ApplyManifestDraft(context.Background(), root, ApplyManifestInput{Revision: rev, Workspace: binding("new"), Changes: []ProjectManifestPatch{{Project: name}}}) - if err == nil { - t.Fatal("accepted project edit") - } - after, _ := os.ReadFile(path) - if !bytes.Equal(original, after) { - t.Fatal("partially published") - } - } -} -func TestInvalidBindingCannotBePublished(t *testing.T) { - root, s, rev := seedManifest(t) - path := workspacecore.ManifestPath(root) - original, _ := os.ReadFile(path) - for _, site := range []string{"http://external.example", "https://user:pass@example.com", "https://example.com/?token=x"} { - patch := binding("new") - patch.Environment.SiteURL = &site - _, err := s.ApplyManifestDraft(context.Background(), root, ApplyManifestInput{Revision: rev, Workspace: patch}) - if !errors.Is(err, ErrInvalidInput) { - t.Fatal(err) - } - } - after, _ := os.ReadFile(path) - if !bytes.Equal(original, after) { - t.Fatal("invalid binding changed source") - } -} diff --git a/packages/cli/internal/application/workspace/project_build_settings_test.go b/packages/cli/internal/application/workspace/project_build_settings_test.go deleted file mode 100644 index cc3025ab..00000000 --- a/packages/cli/internal/application/workspace/project_build_settings_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package workspace - -import ( - "context" - "os" - "path/filepath" - "testing" - - catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" - workspacecore "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" -) - -func TestProjectSettingsReadsLiveBuildTaskWithoutWriting(t *testing.T) { - for _, tc := range []struct { - name, toolchain, file, content, command, status string - }{ - {"node", "node", "package.json", `{"scripts":{"build":"echo build"}}`, "pnpm run build", "ready"}, - {"node without build", "node", "package.json", `{"scripts":{"dev":"vite"}}`, "", "missing"}, - {"invalid package", "node", "package.json", `{invalid`, "", "invalid"}, - {"missing package", "node", "", "", "", "invalid"}, - {"go", "go", "Taskfile.yml", "version: '3'\ntasks:\n build:\n cmds: ['go build ./...']\n", "task build", "ready"}, - {"go without build", "go", "Taskfile.yml", "version: '3'\ntasks: {}\n", "", "missing"}, - {"invalid taskfile", "go", "Taskfile.yml", "tasks: [", "", "invalid"}, - {"missing taskfile", "go", "", "", "", "missing"}, - } { - t.Run(tc.name, func(t *testing.T) { - root := seedProjectSettingsWorkspace(t) - manifest, err := workspacecore.ReadManifest(root) - if err != nil { - t.Fatal(err) - } - manifest.Projects[0].Toolchain = tc.toolchain - if err := workspacecore.WriteManifest(root, manifest); err != nil { - t.Fatal(err) - } - write := func(path, contents string) { - t.Helper() - target := filepath.Join(root, path) - if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(target, []byte(contents), 0o644); err != nil { - t.Fatal(err) - } - } - write("package.json", `{"packageManager":"pnpm@12.3.4"}`) - if tc.file != "" { - write(filepath.Join("apps/web", tc.file), tc.content) - } - before := snapshotWorkspaceTree(t, root) - service, err := NewService(catalog.Builtin()) - if err != nil { - t.Fatal(err) - } - settings, err := service.ProjectSettings(context.Background(), root, "web", "dev") - if err != nil { - t.Fatal(err) - } - source := "package.json#scripts.build" - if tc.toolchain == "go" { - source = "Taskfile.yml#tasks.build" - } - want := ProjectBuildSettings{Command: tc.command, Source: source, Status: tc.status} - if settings.Project.Build != want { - t.Fatalf("build = %#v, want %#v", settings.Project.Build, want) - } - assertWorkspaceTreeEqual(t, snapshotWorkspaceTree(t, root), before) - if tc.name == "node" { - write("apps/web/package.json", `{"scripts":{}}`) - refreshed, err := service.ProjectSettings(context.Background(), root, "web", "dev") - if err != nil { - t.Fatal(err) - } - if refreshed.Project.Build.Status != "missing" || refreshed.Project.Build.Command != "" || refreshed.Revision != settings.Revision { - t.Fatalf("build did not refresh independently of manifest: %#v", refreshed) - } - } - }) - } -} diff --git a/packages/cli/internal/application/workspace/project_settings_test.go b/packages/cli/internal/application/workspace/project_settings_test.go deleted file mode 100644 index f302e9a9..00000000 --- a/packages/cli/internal/application/workspace/project_settings_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package workspace - -import ( - "bytes" - "context" - "encoding/json" - "io/fs" - "os" - "path/filepath" - "strings" - "testing" - - catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" - workspacecore "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" -) - -func seedProjectSettingsWorkspace(t *testing.T) string { - t.Helper() - root := t.TempDir() - manifest := &workspacecore.Manifest{ - Version: workspacecore.ManifestVersion, - Workspace: &workspacecore.ManifestWorkspace{ID: "ws-demo", Name: "Demo"}, - - Env: &workspacecore.EnvironmentConfig{ProjectID: "remote", Environments: []string{"dev", "staging", "prod"}}, - - Projects: []workspacecore.ManifestProject{{ - Name: "web", RelativeDir: "apps/web", Toolchain: "node", - }}, - } - if err := workspacecore.WriteManifest(root, manifest); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("repository data\n"), 0o644); err != nil { - t.Fatal(err) - } - return root -} - -func snapshotWorkspaceTree(t *testing.T, root string) map[string][]byte { - t.Helper() - result := map[string][]byte{} - err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - if entry.IsDir() { - return nil - } - relative, err := filepath.Rel(root, path) - if err != nil { - return err - } - value, err := os.ReadFile(path) - if err != nil { - return err - } - result[filepath.ToSlash(relative)] = value - return nil - }) - if err != nil { - t.Fatal(err) - } - return result -} - -func assertWorkspaceTreeEqual(t *testing.T, got, want map[string][]byte) { - t.Helper() - if len(got) != len(want) { - t.Fatalf("workspace file count changed: got %d want %d", len(got), len(want)) - } - for path, expected := range want { - if !bytes.Equal(got[path], expected) { - t.Fatalf("workspace file %q changed", path) - } - } -} - -func TestProjectSettingsReturnsEnvironmentAwareSafeProjection(t *testing.T) { - root := seedProjectSettingsWorkspace(t) - service, err := NewService(catalog.Builtin()) - if err != nil { - t.Fatal(err) - } - settings, err := service.ProjectSettings(context.Background(), root, "web", "staging") - if err != nil { - t.Fatal(err) - } - project := settings.Project - if settings.Schema != ProjectSettingsSchema || settings.Environment != "staging" || - project.Kind != workspacecore.ProjectKindApp { - t.Fatalf("unexpected envelope: %#v", settings) - } - - if project.Environment.Path != "/apps/web" || !project.Environment.Inherits { - t.Fatalf("unexpected scope: %+v", project.Environment) - } - raw, err := json.Marshal(settings) - if err != nil { - t.Fatal(err) - } - if strings.Contains(string(raw), "must-not-leak") || strings.Contains(string(raw), "never-return") { - t.Fatalf("settings leaked a credential: %s", raw) - } -} diff --git a/packages/cli/internal/application/workspace/registry_service_test.go b/packages/cli/internal/application/workspace/registry_service_test.go deleted file mode 100644 index b06df8e4..00000000 --- a/packages/cli/internal/application/workspace/registry_service_test.go +++ /dev/null @@ -1,410 +0,0 @@ -package workspace - -import ( - "context" - "errors" - "fmt" - "os" - "path/filepath" - "testing" - "time" - - registrylocal "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/workspaceregistry/local" - workspacecore "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" -) - -func TestRegistryObserveIsIdempotentForSamePath(t *testing.T) { - root := createRegistryTestWorkspace(t, "same-id", "same", 2) - path := filepath.Join(t.TempDir(), "workspaces.json") - store := registrylocal.NewAt(path) - now := time.Date(2026, time.August, 30, 10, 0, 0, 0, time.UTC) - generated := 0 - service := newRegistryTestService(t, store, func() time.Time { return now }, func() (string, error) { - generated++ - return fmt.Sprintf("wsr_%d", generated), nil - }) - - first, err := service.Observe(context.Background(), root, "create") - if err != nil { - t.Fatal(err) - } - now = now.Add(time.Hour) - second, err := service.Observe(context.Background(), root, "serve") - if err != nil { - t.Fatal(err) - } - if first.EntryID != second.EntryID { - t.Fatalf("same root changed entry ID: %q -> %q", first.EntryID, second.EntryID) - } - if generated != 1 { - t.Fatalf("entry IDs generated = %d, want 1", generated) - } - if second.Status != WorkspaceStatusReady || second.ProjectCount != 2 { - t.Fatalf("second observation = %#v", second) - } - - registry, err := store.Load(context.Background()) - if err != nil { - t.Fatal(err) - } - if len(registry.Workspaces) != 1 { - t.Fatalf("registry entries = %d, want 1", len(registry.Workspaces)) - } - entry := registry.Workspaces[0] - if !entry.RegisteredAt.Equal(now.Add(-time.Hour)) { - t.Fatalf("RegisteredAt changed: %s", entry.RegisteredAt) - } - if !entry.LastSeenAt.Equal(now) || entry.LastSeenBy != "serve" { - t.Fatalf("last observation not refreshed: %#v", entry) - } -} - -func TestRegistryObserveCanonicalizesSymlinkPath(t *testing.T) { - root := createRegistryTestWorkspace(t, "symlink-id", "symlink", 0) - alias := filepath.Join(t.TempDir(), "workspace-alias") - if err := os.Symlink(root, alias); err != nil { - t.Skipf("symlinks unavailable: %v", err) - } - store := registrylocal.NewAt(filepath.Join(t.TempDir(), "workspaces.json")) - service := newRegistryTestService(t, store, time.Now, sequenceEntryIDs()) - - first, err := service.Observe(context.Background(), alias, "serve") - if err != nil { - t.Fatal(err) - } - second, err := service.Observe(context.Background(), root, "serve") - if err != nil { - t.Fatal(err) - } - wantRoot, err := filepath.EvalSymlinks(root) - if err != nil { - t.Fatal(err) - } - if first.EntryID != second.EntryID || first.Root != wantRoot || second.Root != wantRoot { - t.Fatalf("symlink observations were not canonicalized: first=%#v second=%#v", first, second) - } -} - -func TestRegistryObserveMovePreservesEntryID(t *testing.T) { - base := t.TempDir() - oldRoot := filepath.Join(base, "old") - newRoot := filepath.Join(base, "new") - if err := os.Mkdir(oldRoot, 0o755); err != nil { - t.Fatal(err) - } - writeRegistryTestManifest(t, oldRoot, "move-id", "moved", 1) - store := registrylocal.NewAt(filepath.Join(t.TempDir(), "workspaces.json")) - service := newRegistryTestService(t, store, time.Now, sequenceEntryIDs()) - - before, err := service.Observe(context.Background(), oldRoot, "create") - if err != nil { - t.Fatal(err) - } - if err := os.Rename(oldRoot, newRoot); err != nil { - t.Fatal(err) - } - after, err := service.Observe(context.Background(), newRoot, "serve") - if err != nil { - t.Fatal(err) - } - wantNewRoot, err := filepath.EvalSymlinks(newRoot) - if err != nil { - t.Fatal(err) - } - if after.EntryID != before.EntryID { - t.Fatalf("move changed entry ID: %q -> %q", before.EntryID, after.EntryID) - } - if after.Root != wantNewRoot || after.Status != WorkspaceStatusReady { - t.Fatalf("moved observation = %#v", after) - } - registry, err := store.Load(context.Background()) - if err != nil { - t.Fatal(err) - } - if len(registry.Workspaces) != 1 || registry.Workspaces[0].Root != wantNewRoot { - t.Fatalf("move left stale entries: %#v", registry.Workspaces) - } -} - -func TestRegistryCopiedWorkspaceCreatesIdentityConflict(t *testing.T) { - firstRoot := createRegistryTestWorkspace(t, "copied-id", "original", 1) - secondRoot := createRegistryTestWorkspace(t, "copied-id", "copy", 3) - store := registrylocal.NewAt(filepath.Join(t.TempDir(), "workspaces.json")) - service := newRegistryTestService(t, store, time.Now, sequenceEntryIDs()) - - first, err := service.Observe(context.Background(), firstRoot, "serve") - if err != nil { - t.Fatal(err) - } - second, err := service.Observe(context.Background(), secondRoot, "serve") - if err != nil { - t.Fatal(err) - } - if first.EntryID == second.EntryID { - t.Fatalf("active copied workspaces shared entry ID %q", first.EntryID) - } - if second.Status != WorkspaceStatusIdentityConflict { - t.Fatalf("second status = %q, want identity conflict", second.Status) - } - - listed, err := service.List(context.Background(), "") - if err != nil { - t.Fatal(err) - } - if len(listed.Workspaces) != 2 { - t.Fatalf("listed workspaces = %d, want 2", len(listed.Workspaces)) - } - for _, registered := range listed.Workspaces { - if registered.Status != WorkspaceStatusIdentityConflict { - t.Errorf("copy %s status = %q", registered.EntryID, registered.Status) - } - if _, resolveErr := service.Resolve(context.Background(), registered.EntryID); !errors.Is(resolveErr, ErrRegistryIdentityConflict) { - t.Errorf("Resolve(%s) error = %v, want identity conflict", registered.EntryID, resolveErr) - } - } - for _, test := range []struct { - entryID string - root string - }{ - {entryID: first.EntryID, root: first.Root}, - {entryID: second.EntryID, root: second.Root}, - } { - resolved, resolveErr := service.ResolveRead(context.Background(), test.entryID) - if resolveErr != nil { - t.Errorf("ResolveRead(%s) error = %v", test.entryID, resolveErr) - continue - } - if resolved.Root != test.root || resolved.Status != WorkspaceStatusIdentityConflict { - t.Errorf("ResolveRead(%s) = %#v", test.entryID, resolved) - } - } -} - -func TestRegistryRejectsInvalidIdentity(t *testing.T) { - root := t.TempDir() - if err := os.WriteFile(workspacecore.ManifestPath(root), []byte("version=2\n[workspace]\nid=''\nname='Invalid'\n"), 0644); err != nil { - t.Fatal(err) - } - service := newRegistryTestService(t, registrylocal.NewAt(filepath.Join(t.TempDir(), "workspaces.json")), time.Now, sequenceEntryIDs()) - if _, err := service.Observe(context.Background(), root, "serve"); err == nil { - t.Fatal("invalid identity accepted") - } -} - -func TestRegistryListPrunesMissingRootWithoutDeletingWorkspaceFiles(t *testing.T) { - base := t.TempDir() - root := filepath.Join(base, "workspace") - relocatedRoot := filepath.Join(base, "workspace-relocated") - if err := os.Mkdir(root, 0o755); err != nil { - t.Fatal(err) - } - writeRegistryTestManifest(t, root, "missing-id", "missing", 1) - service := newRegistryTestService( - t, - registrylocal.NewAt(filepath.Join(t.TempDir(), "workspaces.json")), - time.Now, - sequenceEntryIDs(), - ) - registered, err := service.Observe(context.Background(), root, "serve") - if err != nil { - t.Fatal(err) - } - if err := os.Rename(root, relocatedRoot); err != nil { - t.Fatal(err) - } - - listed, err := service.List(context.Background(), "") - if err != nil { - t.Fatal(err) - } - if len(listed.Workspaces) != 0 { - t.Fatalf("missing registry entry remained visible: %#v", listed.Workspaces) - } - if !workspacecore.HasManifest(relocatedRoot) { - t.Fatal("registry pruning changed or deleted the relocated Workspace") - } - registry, err := service.repository.Load(context.Background()) - if err != nil { - t.Fatal(err) - } - if len(registry.Workspaces) != 0 { - t.Fatalf("missing registry entry remained on disk: %#v", registry.Workspaces) - } - _, err = service.Resolve(context.Background(), registered.EntryID) - if !errors.Is(err, ErrRegistryEntryNotFound) { - t.Fatalf("pruned Resolve error = %v, want not found", err) - } -} - -func TestRegistryRootPruningRequiresAvailableVolume(t *testing.T) { - missingRoot := filepath.Join(t.TempDir(), "gone") - if !registryRootDefinitelyMissingWithStat(missingRoot, func(path string) error { - if path == missingRoot { - return os.ErrNotExist - } - return nil - }) { - t.Fatal("missing root on an available volume was not prunable") - } - if registryRootDefinitelyMissingWithStat(missingRoot, func(string) error { - return os.ErrNotExist - }) { - t.Fatal("missing root on an unavailable volume was prunable") - } - if registryRootDefinitelyMissingWithStat(missingRoot, func(path string) error { - if path == missingRoot { - return os.ErrPermission - } - return nil - }) { - t.Fatal("permission-denied root was prunable") - } -} - -func TestRegistryExistingRootWithoutManifestIsInvalid(t *testing.T) { - root := t.TempDir() - store := registrylocal.NewAt(filepath.Join(t.TempDir(), "workspaces.json")) - now := time.Now().UTC() - if err := store.Update(context.Background(), func(registry *workspacecore.Registry) error { - registry.Workspaces = append(registry.Workspaces, workspacecore.RegistryEntry{ - EntryID: "wsr_invalid", - WorkspaceID: "invalid-id", - Name: "invalid", - Root: root, - RegisteredAt: now, - LastSeenAt: now, - }) - return nil - }); err != nil { - t.Fatal(err) - } - service := newRegistryTestService(t, store, time.Now, sequenceEntryIDs()) - - listed, err := service.List(context.Background(), "") - if err != nil { - t.Fatal(err) - } - if len(listed.Workspaces) != 1 || listed.Workspaces[0].Status != WorkspaceStatusInvalid { - t.Fatalf("manifest-less root list = %#v", listed.Workspaces) - } - if _, err := service.Observe(context.Background(), root, "serve"); !errors.Is(err, ErrRegistryUnavailable) { - t.Fatalf("manifest-less Observe error = %v, want unavailable", err) - } - if _, err := service.Resolve(context.Background(), "wsr_invalid"); !errors.Is(err, ErrRegistryUnavailable) { - t.Fatalf("manifest-less Resolve error = %v, want unavailable", err) - } -} - -func TestRegistryResolveRevalidatesManifestIdentity(t *testing.T) { - root := createRegistryTestWorkspace(t, "before-id", "before", 1) - service := newRegistryTestService( - t, - registrylocal.NewAt(filepath.Join(t.TempDir(), "workspaces.json")), - time.Now, - sequenceEntryIDs(), - ) - registered, err := service.Observe(context.Background(), root, "serve") - if err != nil { - t.Fatal(err) - } - - resolved, err := service.Resolve(context.Background(), registered.EntryID) - if err != nil { - t.Fatal(err) - } - if resolved.Root != registered.Root || resolved.Manifest == nil || resolved.Manifest.Workspace.ID != "before-id" { - t.Fatalf("resolved = %#v", resolved) - } - - writeRegistryTestManifest(t, root, "after-id", "after", 1) - _, err = service.Resolve(context.Background(), registered.EntryID) - if !errors.Is(err, ErrRegistryIdentityConflict) { - t.Fatalf("changed identity Resolve error = %v, want identity conflict", err) - } -} - -func TestRegistryForgetRemovesOnlyRegistration(t *testing.T) { - root := createRegistryTestWorkspace(t, "forget-id", "forget", 1) - service := newRegistryTestService( - t, - registrylocal.NewAt(filepath.Join(t.TempDir(), "workspaces.json")), - time.Now, - sequenceEntryIDs(), - ) - registered, err := service.Observe(context.Background(), root, "serve") - if err != nil { - t.Fatal(err) - } - if err := service.Forget(context.Background(), registered.EntryID); err != nil { - t.Fatal(err) - } - if !workspacecore.HasManifest(root) { - t.Fatal("Forget removed or changed the workspace") - } - listed, err := service.List(context.Background(), "") - if err != nil { - t.Fatal(err) - } - if len(listed.Workspaces) != 0 { - t.Fatalf("workspaces after Forget = %#v", listed.Workspaces) - } - if err := service.Forget(context.Background(), registered.EntryID); !errors.Is(err, ErrRegistryEntryNotFound) { - t.Fatalf("second Forget error = %v, want not found", err) - } -} - -func newRegistryTestService( - t *testing.T, - store *registrylocal.Store, - clock func() time.Time, - entryIDs func() (string, error), -) *RegistryService { - t.Helper() - service, err := NewRegistryService( - store, - WithRegistryClock(clock), - WithRegistryEntryIDGenerator(entryIDs), - ) - if err != nil { - t.Fatal(err) - } - return service -} - -func sequenceEntryIDs() func() (string, error) { - next := 0 - return func() (string, error) { - next++ - return fmt.Sprintf("wsr_%d", next), nil - } -} - -func createRegistryTestWorkspace(t *testing.T, id string, name string, projectCount int) string { - t.Helper() - root := t.TempDir() - writeRegistryTestManifest(t, root, id, name, projectCount) - return root -} - -func writeRegistryTestManifest(t *testing.T, root string, id string, name string, projectCount int) { - t.Helper() - projects := make([]workspacecore.ManifestProject, 0, projectCount) - for index := 0; index < projectCount; index++ { - projects = append(projects, workspacecore.ManifestProject{ - Name: fmt.Sprintf("project-%d", index), - RelativeDir: fmt.Sprintf("apps/project-%d", index), - Toolchain: "node", - }) - } - manifest := &workspacecore.Manifest{ - Version: workspacecore.ManifestVersion, - Workspace: &workspacecore.ManifestWorkspace{ - ID: id, - Name: name, - }, - Projects: projects, - } - if err := workspacecore.WriteManifest(root, manifest); err != nil { - t.Fatal(err) - } -} diff --git a/packages/cli/internal/application/workspace/service_test.go b/packages/cli/internal/application/workspace/service_test.go deleted file mode 100644 index 0d8a5bed..00000000 --- a/packages/cli/internal/application/workspace/service_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package workspace - -import ( - "testing" - - catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" - workspacecore "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" -) - -func TestServiceOverviewIsAReadOnlyProjection(t *testing.T) { - root := seedProjectSettingsWorkspace(t) - before := snapshotWorkspaceTree(t, root) - service, err := NewService(catalog.Builtin()) - if err != nil { - t.Fatal(err) - } - overview, err := service.Overview(root, "preview") - if err != nil { - t.Fatal(err) - } - if !overview.Present || overview.Root != root || len(overview.Projects) != 1 || - overview.Projects[0].Name != "web" { - t.Fatalf("overview = %#v", overview) - } - assertWorkspaceTreeEqual(t, snapshotWorkspaceTree(t, root), before) -} - -func TestFindProjectDoesNotInventManifestState(t *testing.T) { - manifest := &workspacecore.Manifest{Projects: []workspacecore.ManifestProject{{Name: "web"}}} - if findProject(manifest, "web") == nil || findProject(manifest, "ghost") != nil { - t.Fatalf("findProject returned unexpected result") - } -} diff --git a/packages/cli/internal/architecture/dependencies_test.go b/packages/cli/internal/architecture/dependencies_test.go deleted file mode 100644 index 8ba73dad..00000000 --- a/packages/cli/internal/architecture/dependencies_test.go +++ /dev/null @@ -1,118 +0,0 @@ -package architecture_test - -import ( - "go/parser" - "go/token" - "io/fs" - "os" - "path/filepath" - "runtime" - "strconv" - "strings" - "testing" -) - -const internalImportPrefix = "github.com/torchstellar-team/one-cli/packages/cli/internal/" - -// TestDependencyDirection turns the documented dependency rules into an -// executable constraint. Bootstrap is the outer composition root; platform -// and resources are leaves shared by the layers above them. -func TestDependencyDirection(t *testing.T) { - forbidden := map[string][]string{ - "platform": {"resources", "core", "ports", "application", "adapters", "modules", "transport", "bootstrap"}, - "resources": {"platform", "core", "ports", "application", "adapters", "modules", "transport", "bootstrap"}, - "core": {"ports", "application", "adapters", "modules", "transport", "bootstrap"}, - "ports": {"resources", "application", "adapters", "modules", "transport", "bootstrap"}, - "application": {"resources", "adapters", "modules", "transport", "bootstrap"}, - "adapters": {"resources", "application", "modules", "transport", "bootstrap"}, - "modules": {"transport", "bootstrap"}, - "transport": {"adapters", "bootstrap"}, - } - - _, currentFile, _, ok := runtimeCaller() - if !ok { - t.Fatal("resolve architecture test path") - } - internalRoot := filepath.Dir(filepath.Dir(currentFile)) - - for layer, blockedLayers := range forbidden { - layer := layer - blockedLayers := blockedLayers - t.Run(layer, func(t *testing.T) { - root := filepath.Join(internalRoot, layer) - err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - // Embedded template sources are data, not part of the CLI's Go - // dependency graph. - if entry.IsDir() && strings.HasPrefix(entry.Name(), "_") { - return filepath.SkipDir - } - // Unit tests may use a concrete adapter as a fixture. Production - // packages are the dependency graph this guard protects. - if entry.IsDir() || filepath.Ext(path) != ".go" || strings.HasSuffix(path, "_test.go") { - return nil - } - parsed, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly) - if err != nil { - return err - } - for _, imported := range parsed.Imports { - importPath, err := strconv.Unquote(imported.Path.Value) - if err != nil || !strings.HasPrefix(importPath, internalImportPrefix) { - continue - } - relativeImport := strings.TrimPrefix(importPath, internalImportPrefix) - for _, blocked := range blockedLayers { - if relativeImport == blocked || strings.HasPrefix(relativeImport, blocked+"/") { - relativeFile, _ := filepath.Rel(internalRoot, path) - t.Errorf("%s imports outer layer %s", filepath.ToSlash(relativeFile), importPath) - } - } - } - return nil - }) - if err != nil { - t.Fatal(err) - } - }) - } -} - -// TestDashboardWorkspaceApplicationCannotWriteManifest makes the product -// boundary executable: the Dashboard application may project repository -// state, but its only mutations are machine-local profile bindings. -func TestDashboardWorkspaceApplicationCannotWriteManifest(t *testing.T) { - _, currentFile, _, ok := runtimeCaller() - if !ok { - t.Fatal("resolve architecture test path") - } - root := filepath.Join(filepath.Dir(filepath.Dir(currentFile)), "application", "workspace") - err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - if entry.IsDir() || filepath.Ext(path) != ".go" || strings.HasSuffix(path, "_test.go") { - return nil - } - contents, err := os.ReadFile(path) - if err != nil { - return err - } - if strings.Contains(string(contents), "WriteManifest") { - relative, _ := filepath.Rel(root, path) - t.Errorf("application/workspace/%s may not call WriteManifest", filepath.ToSlash(relative)) - } - return nil - }) - if err != nil { - t.Fatal(err) - } -} - -// runtimeCaller is a small seam that keeps the path lookup readable and easy -// to replace if this package is ever moved. -var runtimeCaller = func() (uintptr, string, int, bool) { - return runtime.Caller(0) -} diff --git a/packages/cli/internal/bootstrap/cli/root_test.go b/packages/cli/internal/bootstrap/cli/root_test.go deleted file mode 100644 index 6b66363f..00000000 --- a/packages/cli/internal/bootstrap/cli/root_test.go +++ /dev/null @@ -1,196 +0,0 @@ -package cli - -import ( - "slices" - "strings" - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" -) - -func TestScanOutputValue(t *testing.T) { - cases := []struct { - name string - args []string - want string - }{ - {"none", []string{"templates"}, ""}, - {"-o space json", []string{"templates", "-o", "json"}, "json"}, - {"--output space json", []string{"templates", "--output", "json"}, "json"}, - {"-o equals", []string{"templates", "-o=json"}, "json"}, - {"--output equals", []string{"templates", "--output=json"}, "json"}, - {"-ojson concatenated", []string{"templates", "-ojson"}, "json"}, - {"-o text", []string{"-o", "text", "templates"}, "text"}, - {"-o trailing without value", []string{"templates", "-o"}, ""}, - {"first wins", []string{"-o", "json", "--output", "text"}, "json"}, - {"flag mid-args", []string{"add", "nestjs-api", "-o", "json", "--name", "x"}, "json"}, - {"unknown flag without value", []string{"-x", "templates"}, ""}, - {"skills flags belong upstream", []string{"skills", "find", "x", "--output", "json"}, ""}, - {"One output before skills", []string{"-o", "text", "skills", "list", "--output", "json"}, "text"}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - if got := scanOutputValue(c.args); got != c.want { - t.Errorf("scanOutputValue(%q) = %q, want %q", c.args, got, c.want) - } - }) - } -} - -func TestDetectOutputMode_TextForcesHuman(t *testing.T) { - t.Cleanup(func() { output.SetMode(output.ModeAuto) }) - - output.SetMode(output.ModeJSON) // dirty prior state - detectOutputMode([]string{"templates", "-o", "text"}) - if output.IsJSON() { - t.Errorf("-o text should force human format: IsJSON() = true") - } -} - -func TestDetectOutputMode_JSONForces(t *testing.T) { - t.Cleanup(func() { output.SetMode(output.ModeAuto) }) - - output.SetMode(output.ModeAuto) - detectOutputMode([]string{"templates", "-o", "json"}) - if !output.IsJSON() { - t.Errorf("-o json should force JSON: IsJSON() = false") - } -} - -func TestDetectOutputMode_UnknownValueFallsThrough(t *testing.T) { - t.Cleanup(func() { output.SetMode(output.ModeAuto) }) - - output.SetMode(output.ModeJSON) - detectOutputMode([]string{"templates", "-o", "bogus"}) - // unknown value: kubectl-style leniency — leaves prior mode untouched - if !output.IsJSON() { - t.Errorf("unknown -o value should not switch off ModeJSON") - } -} - -func TestFirstPositional(t *testing.T) { - cases := []struct { - name string - args []string - want string - wantOK bool - }{ - {"empty", nil, "", false}, - {"flag only", []string{"--help"}, "", false}, - {"help token suppresses", []string{"help", "create"}, "", false}, - {"plain positional", []string{"create"}, "create", true}, - {"flag before positional", []string{"-o", "json", "templates"}, "templates", true}, - {"long flag before positional", []string{"--output", "json", "templates"}, "templates", true}, - {"-o=json before positional", []string{"-o=json", "templates"}, "templates", true}, - {"--output=json before positional", []string{"--output=json", "templates"}, "templates", true}, - {"-ojson before positional", []string{"-ojson", "templates"}, "templates", true}, - {"empty string skipped", []string{"", "templates"}, "templates", true}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - got, ok := firstPositional(c.args) - if got != c.want || ok != c.wantOK { - t.Errorf("firstPositional(%q) = (%q, %v), want (%q, %v)", c.args, got, ok, c.want, c.wantOK) - } - }) - } -} - -func TestShouldRenderRootHelp(t *testing.T) { - cases := []struct { - name string - args []string - want bool - }{ - {"bare", nil, true}, - {"-h", []string{"-h"}, true}, - {"--help", []string{"--help"}, true}, - {"help with no follow", []string{"help"}, true}, - {"help unknown", []string{"help", "bogus"}, true}, - {"help known subcommand", []string{"help", "create"}, false}, - {"subcommand", []string{"templates"}, false}, - {"subcommand with help", []string{"templates", "--help"}, false}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - if got := shouldRenderRootHelp(c.args); got != c.want { - t.Errorf("shouldRenderRootHelp(%q) = %v, want %v", c.args, got, c.want) - } - }) - } -} - -func TestShouldRenderAllHelp(t *testing.T) { - if !shouldRenderAllHelp([]string{"help", "--all"}) { - t.Fatal("one help --all should select the complete command catalogue") - } - for _, args := range [][]string{{"--help"}, {"help"}, {"help", "create"}, {"help", "--all", "extra"}} { - if shouldRenderAllHelp(args) { - t.Errorf("shouldRenderAllHelp(%q) = true", args) - } - } -} - -func TestIsBareInvocation(t *testing.T) { - for _, args := range [][]string{nil, {"-o", "json"}, {"--output=text"}, {"-ojson"}} { - if !isBareInvocation(args) { - t.Errorf("isBareInvocation(%q) = false", args) - } - } - for _, args := range [][]string{{"--help"}, {"dev"}, {"-o"}, {"-o", "json", "dev"}} { - if isBareInvocation(args) { - t.Errorf("isBareInvocation(%q) = true", args) - } - } -} - -func TestRootHelpDoesNotAdvertiseUnregisteredCommands(t *testing.T) { - // The curated help text lives in i18n/locales/*.json and is - // fetched via RootHelp(). Check every locale so a new tongue - // can't reintroduce the historical surfaces. - for _, key := range []string{"root.help"} { - text := i18n.T(key) - for _, token := range []string{"one prd", "one design", "PRODUCT / DESIGN"} { - if strings.Contains(text, token) { - t.Fatalf("%s advertises unregistered command surface %q", key, token) - } - } - } -} - -func TestIsKnownSubcommand(t *testing.T) { - // Every command is assembled by the composition root. - for _, name := range []string{ - "create", "templates", "add", "exec", "run", - "env", "serve", "mise", "hk", "skills", - "login", "whoami", "logout", "locale", "init", "upgrade", - } { - if !isKnownSubcommand(name) { - t.Errorf("isKnownSubcommand(%q) = false, want true", name) - } - } - // Task names and removed commands are not built-in subcommands. - for _, name := range []string{ - "dev", "build", "test", "lint", "update", - "doctor", "status", "unknown", "secrets", "skill", "prd", "design", - "docker", "infisical", "dotenv", "procs", "compose", "k8s", - "plugins", "setup", "profile", "container", "deploy", "ci", - "", - } { - if isKnownSubcommand(name) { - t.Errorf("isKnownSubcommand(%q) = true, want false", name) - } - } -} - -func TestManualUpdateDoesNotStartBackgroundUpdater(t *testing.T) { - for _, args := range [][]string{{"upgrade"}, {"-o", "json", "upgrade"}, {"--output=yaml", "upgrade"}, {"upgrade", "--help"}} { - if shouldCheckUpdates(args) { - t.Fatalf("manual update starts background updater: %v", args) - } - if got := expandTaskShorthand(args, isKnownSubcommand); !slices.Equal(got, args) { - t.Fatalf("manual update was rewritten as a task: %v -> %v", args, got) - } - } -} diff --git a/packages/cli/internal/bootstrap/cli/task_shorthand_test.go b/packages/cli/internal/bootstrap/cli/task_shorthand_test.go deleted file mode 100644 index 3a89738e..00000000 --- a/packages/cli/internal/bootstrap/cli/task_shorthand_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package cli - -import ( - "reflect" - "testing" -) - -func TestTaskShorthandPreservesCommandBoundaries(t *testing.T) { - for _, tc := range []struct{ args, want []string }{ - {[]string{"dev", "-p", "web", "--", "--port", "4300"}, []string{"run", "dev", "-p", "web", "--", "--port", "4300"}}, - {[]string{"--output=json", "build", "--dry-run"}, []string{"--output=json", "run", "build", "--dry-run"}}, - {[]string{"lint", "--help"}, []string{"run", "lint", "--help"}}, - {[]string{"test", "-p", "web", "--", "-o", "result.json"}, []string{"run", "test", "-p", "web", "--", "-o", "result.json"}}, - {[]string{"-o", "json", "docs:build", "--dry-run"}, []string{"-o", "json", "run", "docs:build", "--dry-run"}}, - {[]string{"-ojson", "verify", "--help"}, []string{"-ojson", "run", "verify", "--help"}}, - {[]string{"env", "typo"}, []string{"env", "typo"}}, - {[]string{"help", "env"}, []string{"help", "env"}}, - {[]string{"--", "test"}, []string{"--", "test"}}, - {[]string{"__unknown"}, []string{"__unknown"}}, - {[]string{"--help"}, []string{"--help"}}, - {[]string{"--unknown", "test"}, []string{"--unknown", "test"}}, - } { - before := append([]string{}, tc.args...) - if got := expandTaskShorthand(tc.args, isKnownSubcommand); !reflect.DeepEqual(got, tc.want) { - t.Fatalf("%v => %v, want %v", tc.args, got, tc.want) - } - if !reflect.DeepEqual(tc.args, before) { - t.Fatal("changed input arguments") - } - } - for _, cmd := range rootCmd.Commands() { - for _, name := range append([]string{cmd.Name()}, cmd.Aliases...) { - args := []string{name, "test"} - if got := expandTaskShorthand(args, isKnownSubcommand); !reflect.DeepEqual(got, args) { - t.Fatalf("rewrote built-in %s", name) - } - } - } - if got := scanOutputValue([]string{"test", "--", "-o", "json"}); got != "" { - t.Fatal("child output flag was consumed", got) - } -} diff --git a/packages/cli/internal/core/backend/catalog_test.go b/packages/cli/internal/core/backend/catalog_test.go deleted file mode 100644 index e417e6c4..00000000 --- a/packages/cli/internal/core/backend/catalog_test.go +++ /dev/null @@ -1,165 +0,0 @@ -package backend - -import ( - "encoding/json" - "reflect" - "testing" -) - -func TestBuiltinPairs(t *testing.T) { - t.Parallel() - - got := Builtin().SortedPairs() - want := []string{ - "env/infisical", - } - if !reflect.DeepEqual(got, want) { - t.Fatalf("Builtin().SortedPairs() = %#v, want %#v", got, want) - } -} - -func TestNewRejectsDuplicateAndMalformedSpecs(t *testing.T) { - t.Parallel() - - valid := spec( - BackendID{Domain: DomainEnv, Name: "test"}, - []Capability{CapabilityEnvGet}, - ) - if _, err := New(valid, valid); err == nil { - t.Fatal("New() accepted duplicate backend") - } - - malformed := valid - malformed.Pair = "deploy/test" - if _, err := New(malformed); err == nil { - t.Fatal("New() accepted mismatched pair") - } -} - -func TestCatalogReturnsDefensiveCopies(t *testing.T) { - t.Parallel() - - c := Builtin() - specs := c.All() - specs[0].Capabilities[0] = "mutated" - envSpecs := c.ForDomain(DomainEnv) - envSpecs[0].Capabilities[0] = "mutated" - - got, ok := c.LookupPair("env/infisical") - if !ok { - t.Fatal("env/infisical not found") - } - if got.Capabilities[0] == "mutated" { - t.Fatal("All() leaked mutable catalog storage") - } - env, ok := c.LookupPair("env/infisical") - if !ok { - t.Fatal("env/infisical not found") - } - if env.Capabilities[0] == "mutated" { - t.Fatal("ForDomain() leaked mutable profile field storage") - } -} - -func TestNewRejectsInvalidProjectFieldMetadata(t *testing.T) { - t.Parallel() - - valid := spec( - BackendID{Domain: DomainEnv, Name: "test"}, - []Capability{CapabilityEnvGet}, - ) - valid.Project = ProjectSpec{Configurable: true, Fields: []ProjectFieldSpec{{ - Path: "env", InputName: "environment", Type: ProjectFieldEnvironment, - LabelKey: "project.fields.environment", - }}} - - tests := []struct { - name string - mutate func(*BackendSpec) - }{ - { - name: "fields require configurable flag", - mutate: func(value *BackendSpec) { - value.Project.Configurable = false - }, - }, - { - name: "configurable requires fields", - mutate: func(value *BackendSpec) { - value.Project.Fields = nil - }, - }, - { - name: "complete metadata", - mutate: func(value *BackendSpec) { - value.Project.Fields[0].LabelKey = "" - }, - }, - { - name: "safe config path", - mutate: func(value *BackendSpec) { - value.Project.Fields[0].Path = "credentials//token" - }, - }, - { - name: "known field type", - mutate: func(value *BackendSpec) { - value.Project.Fields[0].Type = "secret" - }, - }, - { - name: "unique path", - mutate: func(value *BackendSpec) { - value.Project.Fields = append(value.Project.Fields, ProjectFieldSpec{ - Path: "env", InputName: "other", Type: ProjectFieldString, - LabelKey: "project.fields.other", - }) - }, - }, - { - name: "unique input name", - mutate: func(value *BackendSpec) { - value.Project.Fields = append(value.Project.Fields, ProjectFieldSpec{ - Path: "other", InputName: "environment", Type: ProjectFieldString, - LabelKey: "project.fields.other", - }) - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - candidate := cloneSpec(valid) - tt.mutate(&candidate) - if _, err := New(candidate); err == nil { - t.Fatalf("New() accepted invalid project schema: %#v", candidate.Project) - } - }) - } -} - -func TestBackendSpecJSONIncludesNormalizedIdentity(t *testing.T) { - t.Parallel() - - backend, ok := Builtin().LookupPair("env/infisical") - if !ok { - t.Fatal("env/infisical not found") - } - raw, err := json.Marshal(backend) - if err != nil { - t.Fatal(err) - } - var got struct { - ID string `json:"id"` - Domain Domain `json:"domain"` - Name string `json:"name"` - } - if err := json.Unmarshal(raw, &got); err != nil { - t.Fatal(err) - } - if got.ID != "env/infisical" || got.Domain != DomainEnv || got.Name != "infisical" { - t.Fatalf("identity = %#v", got) - } - -} diff --git a/packages/cli/internal/core/template/compat_test.go b/packages/cli/internal/core/template/compat_test.go deleted file mode 100644 index 52d2e3bc..00000000 --- a/packages/cli/internal/core/template/compat_test.go +++ /dev/null @@ -1,117 +0,0 @@ -package template - -import ( - "testing" -) - -func TestCheckAllowedBackends_NoConstraint(t *testing.T) { - tpl := Template{ID: "foo"} - got := CheckAllowedBackends(tpl, map[string]string{"deploy": "deploy/k8s"}, "") - if len(got) != 0 { - t.Errorf("template without compat should never warn, got %v", got) - } -} - -func TestCheckAllowedBackends_EmptySelection(t *testing.T) { - tpl := Template{ - ID: "foo", - Compat: map[string][]string{"deploy": {"vercel"}}, - } - got := CheckAllowedBackends(tpl, nil, "") - if len(got) != 0 { - t.Errorf("empty selection means no workspace policy → no warnings, got %v", got) - } -} - -func TestCheckAllowedBackends_OptedOut(t *testing.T) { - tpl := Template{ - ID: "fumadocs-docs", - Compat: map[string][]string{"deploy": {"vercel", "s3"}}, - } - // User explicitly skipped deploy at create time → "deploy" is not - // a key in selection. No warning. - got := CheckAllowedBackends(tpl, map[string]string{"env": "env/dotenv"}, "") - if len(got) != 0 { - t.Errorf("opted-out domain should not warn, got %v", got) - } -} - -func TestCheckAllowedBackends_EmptyAllowedList(t *testing.T) { - tpl := Template{ - ID: "expo-mobile", - Compat: map[string][]string{"deploy": {}}, - } - // `[]` means "template doesn't participate" — no warning even - // if workspace has a deploy plugin selected. - got := CheckAllowedBackends(tpl, map[string]string{"deploy": "deploy/k8s"}, "") - if len(got) != 0 { - t.Errorf("empty allowed list should not warn, got %v", got) - } -} - -func TestCheckAllowedBackends_Compatible(t *testing.T) { - tpl := Template{ - ID: "go-api", - Compat: map[string][]string{"deploy": {"k8s", "docker-compose"}}, - } - got := CheckAllowedBackends(tpl, map[string]string{"deploy": "deploy/k8s"}, "") - if len(got) != 0 { - t.Errorf("compatible selection should not warn, got %v", got) - } -} - -func TestCheckAllowedBackends_Incompatible(t *testing.T) { - tpl := Template{ - ID: "fumadocs-docs", - Compat: map[string][]string{"deploy": {"vercel", "s3"}}, - } - got := CheckAllowedBackends(tpl, map[string]string{"deploy": "deploy/k8s"}, "") - if len(got) != 1 { - t.Fatalf("expected 1 warning, got %d (%v)", len(got), got) - } - w := got[0] - if w.Domain != "deploy" || w.SelectedID != "deploy/k8s" || w.TemplateID != "fumadocs-docs" { - t.Errorf("warning fields wrong: %+v", w) - } - if w.SubprojectName != "" { - t.Errorf("expected empty SubprojectName for add-time check, got %q", w.SubprojectName) - } - msg := w.Message() - if msg == "" { - t.Error("Message() should not be empty") - } -} - -func TestCheckAllowedBackends_WithSubprojectName(t *testing.T) { - tpl := Template{ - ID: "fumadocs-docs", - Compat: map[string][]string{"deploy": {"vercel"}}, - } - got := CheckAllowedBackends(tpl, map[string]string{"deploy": "deploy/k8s"}, "my-docs") - if len(got) != 1 || got[0].SubprojectName != "my-docs" { - t.Errorf("expected SubprojectName=my-docs, got %+v", got) - } -} - -func TestCheckAllowedBackends_DeterministicOrder(t *testing.T) { - tpl := Template{ - ID: "weird", - Compat: map[string][]string{ - "deploy": {"deploy/k8s"}, - "ci": {"ci/github-actions"}, - }, - } - selection := map[string]string{ - "deploy": "deploy/vercel", // mismatch → warning - "ci": "ci/circleci", // mismatch (hypothetical) → warning - } - got := CheckAllowedBackends(tpl, selection, "") - if len(got) != 2 { - t.Fatalf("expected 2 warnings, got %d", len(got)) - } - // Domains should be alphabetical: ci, deploy. - if got[0].Domain != "ci" || got[1].Domain != "deploy" { - t.Errorf("warnings not in alphabetical domain order: %v %v", - got[0].Domain, got[1].Domain) - } -} diff --git a/packages/cli/internal/core/template/electron_template_test.go b/packages/cli/internal/core/template/electron_template_test.go deleted file mode 100644 index d78b2d13..00000000 --- a/packages/cli/internal/core/template/electron_template_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package template - -import ( - "encoding/json" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" -) - -func TestElectronSandboxProfileUsesInstalledBinaryWithoutDisablingSandbox(t *testing.T) { - node, err := exec.LookPath("node") - if err != nil { - t.Skip("node is not installed") - } - root := t.TempDir() - outputs, err := PrepareProjects("electron-app", CommonVariables("desktop", "pnpm")) - if err != nil { - t.Fatal(err) - } - for _, output := range outputs { - for name, raw := range output.Files { - file := filepath.Join(root, output.Name, filepath.FromSlash(name)) - if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(file, raw, 0o644); err != nil { - t.Fatal(err) - } - } - } - project := filepath.Join(root, "desktop-main") - module := filepath.Join(project, "node_modules/electron") - if err := os.MkdirAll(module, 0o755); err != nil { - t.Fatal(err) - } - binary := filepath.ToSlash(filepath.Join(root, "electron [dev]*", "electron")) - value, _ := json.Marshal(binary) - if err := os.WriteFile(filepath.Join(module, "index.js"), []byte("module.exports = "+string(value)), 0o644); err != nil { - t.Fatal(err) - } - cmd := exec.Command(node, "scripts/sandbox-profile.mjs") - cmd.Dir = project - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("profile generator: %v\n%s", err, out) - } - for _, want := range []string{"abi ", "profile one-electron-", "userns,", `electron \[dev\]\*`} { - if !strings.Contains(string(out), want) { - t.Errorf("missing %q in %s", want, out) - } - } - if strings.Contains(string(out), "sudo") || strings.Contains(string(out), "chmod") { - t.Fatalf("profile contains shell commands: %s", out) - } - // Production/default development keeps Chromium's sandbox enabled. - for _, rel := range []string{"desktop-main/package.json", "desktop-main/src/index.ts", "desktop-main/src/windows/main.window.ts"} { - raw, err := os.ReadFile(filepath.Join(root, rel)) - if err != nil { - t.Fatal(err) - } - if strings.Contains(string(raw), "--no-sandbox") || strings.Contains(string(raw), "sandbox: false") { - t.Errorf("sandbox disabled in %s", rel) - } - } - // The generator leaves applying privileges to the administrator. - if _, err := os.Stat(filepath.Join(root, "one-electron.apparmor")); !os.IsNotExist(err) { - t.Fatal("generator wrote a profile automatically") - } -} diff --git a/packages/cli/internal/core/template/go_template_test.go b/packages/cli/internal/core/template/go_template_test.go deleted file mode 100644 index a5c04e8a..00000000 --- a/packages/cli/internal/core/template/go_template_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package template - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "golang.org/x/mod/modfile" -) - -func TestGoAPIDeliversChecksumsForRenderedModule(t *testing.T) { - for _, name := range []string{"api", "billing-service"} { - t.Run(name, func(t *testing.T) { - root := t.TempDir() - if err := Render("go-api", root, CommonVariables(name, "")); err != nil { - t.Fatal(err) - } - b, err := os.ReadFile(filepath.Join(root, "go.mod")) - if err != nil { - t.Fatal(err) - } - m, err := modfile.Parse("go.mod", b, nil) - if err != nil { - t.Fatal(err) - } - if m.Module.Mod.Path != "github.com/example/"+name { - t.Fatalf("module=%s", m.Module.Mod.Path) - } - sums, err := os.ReadFile(filepath.Join(root, "go.sum")) - if err != nil { - t.Fatalf("template missing delivered go.sum: %v", err) - } - for _, dep := range m.Require { - if !strings.Contains(string(sums), dep.Mod.Path+" "+dep.Mod.Version+" h1:") { - t.Errorf("missing checksum for %s@%s", dep.Mod.Path, dep.Mod.Version) - } - } - if err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { - if err != nil { - return err - } - if !d.IsDir() && strings.HasSuffix(path, ".go") { - b, err := os.ReadFile(path) - if err != nil { - return err - } - if strings.Contains(string(b), "{{projectName") { - t.Errorf("unrendered import in %s", path) - } - } - return nil - }); err != nil { - t.Fatal(err) - } - }) - } -} - -func TestGoLibraryDoesNotNeedEmptyChecksumFile(t *testing.T) { - root := t.TempDir() - if err := Render("go-lib", root, CommonVariables("shared", "")); err != nil { - t.Fatal(err) - } - if _, err := os.Stat(filepath.Join(root, "go.sum")); !os.IsNotExist(err) { - t.Fatal("standard-library-only module should not need an empty go.sum") - } -} diff --git a/packages/cli/internal/core/template/list_snapshot_test.go b/packages/cli/internal/core/template/list_snapshot_test.go deleted file mode 100644 index cd52df42..00000000 --- a/packages/cli/internal/core/template/list_snapshot_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package template_test - -// JSON-snapshot parity tests. testdata/reference/ holds the expected -// JSON output for `one templates`; these tests fail loudly on any -// structural drift so agents that have already pinned `one-cli//v1` -// schemas keep working. -// -// We compare on a canonicalised structural level (round-trip through -// json + sort keys) rather than byte-for-byte, because Go's -// encoding/json may pick different — but equivalent — escape sequences -// for non-ASCII strings. The agent contract is "same data shape, same -// field set", not "same bytes". - -import ( - "context" - "encoding/json" - "os" - "path/filepath" - "reflect" - "runtime" - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/template" -) - -func TestSnapshot_TemplatesList(t *testing.T) { - want := loadJSONFixture(t, "templates.json") - - got, err := template.List(context.Background()) - if err != nil { - t.Fatalf("List() error = %v", err) - } - - gotJSON := mustMarshal(t, got) - gotMap := mustUnmarshalMap(t, gotJSON) - - if !reflect.DeepEqual(gotMap, want) { - t.Errorf("templates JSON drift\n want: %s\n got: %s", - pretty(want), pretty(gotMap)) - } -} - -// loadJSONFixture reads testdata/reference/ relative to the module root -// and decodes it as a JSON object. Callers compare against this for parity. -func loadJSONFixture(t *testing.T, name string) map[string]any { - t.Helper() - path := filepath.Join(moduleRoot(t), "testdata", "reference", name) - raw, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read fixture %s: %v", path, err) - } - var v map[string]any - if err := json.Unmarshal(raw, &v); err != nil { - t.Fatalf("decode fixture %s: %v", path, err) - } - return v -} - -func mustMarshal(t *testing.T, v any) []byte { - t.Helper() - b, err := json.Marshal(v) - if err != nil { - t.Fatalf("marshal: %v", err) - } - return b -} - -func mustUnmarshalMap(t *testing.T, b []byte) map[string]any { - t.Helper() - var m map[string]any - if err := json.Unmarshal(b, &m); err != nil { - t.Fatalf("unmarshal: %v", err) - } - return m -} - -func pretty(v any) string { - b, _ := json.MarshalIndent(v, "", " ") - return string(b) -} - -// moduleRoot finds the repo root via the location of this test file. Robust -// against `go test` being run from any directory. -func moduleRoot(t *testing.T) string { - t.Helper() - _, file, _, ok := runtime.Caller(0) - if !ok { - t.Fatalf("runtime.Caller failed") - } - // .../internal/core/template/list_snapshot_test.go → up three levels = CLI module root. - return filepath.Join(filepath.Dir(file), "..", "..", "..") -} diff --git a/packages/cli/internal/core/template/node_test.go b/packages/cli/internal/core/template/node_test.go deleted file mode 100644 index 14862a5b..00000000 --- a/packages/cli/internal/core/template/node_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package template - -import ( - "encoding/json" - "strings" - "testing" -) - -func TestNodePackageRenameIsBoundedAndKeepsDependencyOrder(t *testing.T) { - files := map[string][]byte{ - "package.json": []byte(`{ - "name": "starter", - "workspaces": ["packages/*"], - "scripts": { "dev": "pnpm -F @starter/preload build && pnpm -F @starter/preload-extra build" }, - "custom": { "name": "@starter/preload" } -}`), - "packages/preload/package.json": []byte(`{"name":"@starter/preload"}`), - "packages/ui/package.json": []byte(`{ - "name": "@starter/ui", - "dependencies": { - "@starter/preload": "workspace:*", - "@vendor/lib": "^1.0.0" - }, - "peerDependencies": { "@starter/preload": "workspace:*" }, - "optionalDependencies": { "@starter/preload": "workspace:*" } -}`), - "ui.ts": []byte("import '@starter/preload/channels';\n// @starter/preload\n"), - } - if err := rewriteNode(files, NodeSpec{Scope: "@starter", SourceFiles: []string{"ui.ts"}}, CommonVariables("Zulu_Desktop", "pnpm")); err != nil { - t.Fatal(err) - } - root := string(files["package.json"]) - if !strings.Contains(root, "@zulu-desktop/preload build") || !strings.Contains(root, "@starter/preload-extra build") || !strings.Contains(root, `"custom": { "name": "@starter/preload" }`) { - t.Fatal(root) - } - if string(files["ui.ts"]) != "import '@zulu-desktop/preload/channels';\n// @zulu-desktop/preload\n" { - t.Fatal(string(files["ui.ts"])) - } - raw := string(files["packages/ui/package.json"]) - if strings.Index(raw, `"@vendor/lib"`) > strings.Index(raw, `"@zulu-desktop/preload"`) { - t.Fatal("dependency order:", raw) - } - var pkg map[string]json.RawMessage - if err := json.Unmarshal([]byte(raw), &pkg); err != nil { - t.Fatal(err) - } - for _, key := range []string{"dependencies", "peerDependencies", "optionalDependencies"} { - var deps map[string]string - if err := json.Unmarshal(pkg[key], &deps); err != nil { - t.Fatal(err) - } - if deps["@zulu-desktop/preload"] != "workspace:*" { - t.Fatal(key, deps) - } - } -} - -func TestNodeRenameRejectsDependencyKeyCollision(t *testing.T) { - files := map[string][]byte{ - "package.json": []byte(`{"name":"starter","workspaces":["packages/preload"],"dependencies":{"@starter/preload":"workspace:*","@target/preload":"1.0.0"}}`), - "packages/preload/package.json": []byte(`{"name":"@starter/preload"}`), - } - if err := rewriteNode(files, NodeSpec{Scope: "@starter"}, CommonVariables("target", "pnpm")); err == nil { - t.Fatal("overwrote external dependency") - } -} diff --git a/packages/cli/internal/core/template/projects_test.go b/packages/cli/internal/core/template/projects_test.go deleted file mode 100644 index 27456df9..00000000 --- a/packages/cli/internal/core/template/projects_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package template - -import ( - "encoding/json" - "testing" - "testing/fstest" -) - -func TestCompositeDescriptorRejectsUnsafeAndAmbiguousLocations(t *testing.T) { - base := ProjectSpec{Source: "apps/renderer", Suffix: "-renderer", Category: CategoryFrontend} - for _, test := range []struct { - name string - projects []ProjectSpec - shared []string - }{ - {"traversal", []ProjectSpec{{Source: "../outside", Suffix: "-main", Category: CategoryBackend}}, nil}, - {"duplicate source", []ProjectSpec{base, {Source: base.Source, Suffix: "-main", Category: CategoryBackend}}, nil}, - {"overlap", []ProjectSpec{base, {Source: base.Source + "/nested", Suffix: "-main", Category: CategoryBackend}}, nil}, - {"duplicate suffix", []ProjectSpec{base, {Source: "services/main", Suffix: base.Suffix, Category: CategoryBackend}}, nil}, - {"invalid suffix", []ProjectSpec{{Source: "apps/renderer", Suffix: "/renderer", Category: CategoryFrontend}}, nil}, - {"invalid category", []ProjectSpec{{Source: "apps/renderer", Suffix: "-renderer", Category: Category("desktop")}}, nil}, - {"shared traversal", []ProjectSpec{base}, []string{"../README.md"}}, - {"shared without projects", nil, []string{"README.md"}}, - } { - t.Run(test.name, func(t *testing.T) { - raw, _ := json.Marshal(Spec{SchemaVersion: 1, Projects: test.projects, SharedFiles: test.shared}) - if _, err := readSpec(fstest.MapFS{"template.json": {Data: raw}}); err == nil { - t.Fatal("accepted invalid composite") - } - }) - } -} - -func TestCompositeSplitFailsOnMissingSourcesAndSharedCollisions(t *testing.T) { - spec := Spec{Projects: []ProjectSpec{{Source: "apps/renderer", Suffix: "-renderer", Category: CategoryFrontend}}, SharedFiles: []string{"README.md"}} - for _, files := range []map[string][]byte{ - {"README.md": []byte("shared")}, - {"apps/renderer/package.json": []byte(`{}`)}, - {"README.md": []byte("shared"), "apps/renderer/README.md": []byte("existing")}, - } { - if _, err := splitProjects(files, spec, CommonVariables("desktop", "pnpm")); err == nil { - t.Fatal("accepted incomplete or conflicting component") - } - } -} diff --git a/packages/cli/internal/core/template/render_test.go b/packages/cli/internal/core/template/render_test.go deleted file mode 100644 index 0fc4dd47..00000000 --- a/packages/cli/internal/core/template/render_test.go +++ /dev/null @@ -1,183 +0,0 @@ -package template - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "io/fs" - "os" - "path" - "path/filepath" - "strings" - "testing" - "testing/fstest" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" - "github.com/torchstellar-team/one-cli/packages/cli/internal/resources/bundled" -) - -func TestGoStarterEditsOnlyModuleAndImports(t *testing.T) { - source := fstest.MapFS{ - "template.json": {Data: []byte(`{"schemaVersion":1,"go":{"modulePrefix":"example.com/"}}`)}, - "_go.mod": {Data: []byte("module example.com/starter\n\ngo 1.26.0\n")}, - "go.sum": {Data: []byte("checksums\n")}, - "main_windows.go": {Data: []byte("//go:build windows\n\npackage main\n\nimport (\n alias \"example.com/starter/lib\"\n _ \"example.com/starter-extra/lib\"\n)\n// example.com/starter remains a comment.\nconst value = \"example.com/starter/lib\"\n")}, - "__tests__/empty": {Data: []byte{}}, - "icon.png": {Data: []byte{0, 0xff, 1}}, - } - files, err := prepare(source, CommonVariables("My_API", "")) - if err != nil { - t.Fatal(err) - } - want := strings.Replace(string(source["main_windows.go"].Data), `alias "example.com/starter/lib"`, `alias "example.com/my-api/lib"`, 1) - if string(files["main_windows.go"]) != want { - t.Fatalf("unrelated Go content changed:\n%s", files["main_windows.go"]) - } - if !strings.Contains(string(files["go.mod"]), "module example.com/my-api") { - t.Fatal(string(files["go.mod"])) - } - if !bytes.Equal(files["go.sum"], source["go.sum"].Data) || !bytes.Equal(files["icon.png"], source["icon.png"].Data) { - t.Fatal("copied files changed") - } - target := filepath.Join(t.TempDir(), "output") - if err := publish(target, files); err != nil { - t.Fatal(err) - } - info, err := os.Stat(filepath.Join(target, "__tests__/empty")) - if err != nil || info.Size() != 0 { - t.Fatalf("empty file lost: %v", err) - } - source["nested/_go.mod"] = &fstest.MapFile{Data: []byte("module example.com/other")} - if _, err := prepare(source, CommonVariables("test", "")); err == nil { - t.Fatal("nested module accepted") - } -} - -func TestTextRulesUseOriginalBytes(t *testing.T) { - source := fstest.MapFS{ - "template.json": {Data: []byte(`{"schemaVersion":1,"text":[{"files":["README.md"],"from":"FIRST","value":"projectName"},{"files":["README.md"],"from":"SECOND","value":"projectNameKebabCase"}]}`)}, - "README.md": {Data: []byte("FIRST SECOND\n")}, - } - files, err := prepare(source, Variables{"projectName": "SECOND", "projectNameKebabCase": "final"}) - if err != nil || string(files["README.md"]) != "SECOND final\n" { - t.Fatalf("cascading replacement: %s %v", files["README.md"], err) - } -} - -func TestInvalidTemplatesFailDuringPreparation(t *testing.T) { - tests := []struct { - name, spec string - extra fstest.MapFS - }{ - {"unknown field", `{"schemaVersion":1,"command":"echo unsafe"}`, nil}, - {"unknown version", `{"schemaVersion":2}`, nil}, - {"trailing JSON", `{"schemaVersion":1} {}`, nil}, - {"duplicate JSON key", `{"schemaVersion":1,"schemaVersion":1}`, nil}, - {"escape", `{"schemaVersion":1,"exclude":["../secret"]}`, nil}, - {"absolute", `{"schemaVersion":1,"exclude":["/secret"]}`, nil}, - {"missing file", `{"schemaVersion":1,"text":[{"files":["missing"],"from":"old","value":"projectName"}]}`, nil}, - {"missing match", `{"schemaVersion":1,"text":[{"files":["README.md"],"from":"absent","value":"projectName"}]}`, nil}, - {"overlap", `{"schemaVersion":1,"text":[{"files":["README.md"],"from":"old","value":"projectName"},{"files":["README.md"],"from":"old","value":"projectNameKebabCase"}]}`, nil}, - {"binary", `{"schemaVersion":1,"text":[{"files":["image"],"from":"old","value":"projectName"}]}`, fstest.MapFS{"image": {Data: []byte{0xff, 0}}}}, - {"collision", `{"schemaVersion":1}`, fstest.MapFS{"go.mod": {Data: []byte("module m")}, "_go.mod": {Data: []byte("module m")}}}, - {"legacy file", `{"schemaVersion":1}`, fstest.MapFS{"file.hbs": {Data: []byte("old")}}}, - {"symlink", `{"schemaVersion":1}`, fstest.MapFS{"link": {Data: []byte("/outside"), Mode: fs.ModeSymlink}}}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - source := fstest.MapFS{"template.json": {Data: []byte(tc.spec)}, "README.md": {Data: []byte("old\n")}} - for name, file := range tc.extra { - source[name] = file - } - if _, err := prepare(source, CommonVariables("new", "pnpm")); err == nil { - t.Fatal("invalid template accepted") - } - }) - } -} - -func TestPublishPreservesExistingDirectory(t *testing.T) { - root := t.TempDir() - file := filepath.Join(root, "keep") - if err := os.WriteFile(file, []byte("user"), 0o600); err != nil { - t.Fatal(err) - } - if err := publish(root, map[string][]byte{"keep": []byte("template")}); err == nil { - t.Fatal("overwrote existing directory") - } - raw, _ := os.ReadFile(file) - if string(raw) != "user" { - t.Fatal("user data changed") - } -} - -func TestAllBundledStartersPrepareWithRealNames(t *testing.T) { - registry, err := Fetch(context.Background(), "") - if err != nil { - t.Fatal(err) - } - for _, entry := range registry.Templates { - for _, name := range []string{"sample", "My_API", "billing-service"} { - t.Run(entry.ID+"/"+name, func(t *testing.T) { - source, err := fs.Sub(bundled.TemplatesFS, path.Join(bundled.TemplatesRoot, entry.ID)) - if err != nil { - t.Fatal(err) - } - files, err := prepare(source, CommonVariables(name, "pnpm")) - if err != nil { - t.Fatal(err) - } - for name := range files { - if strings.HasSuffix(name, ".hbs") || name == "template.json" || name == "_go.mod" || name == "go.work" { - t.Fatalf("development file delivered: %s", name) - } - } - if entry.Toolchain == "node" { - var pkg struct{ Name string } - if err := json.Unmarshal(files["package.json"], &pkg); err != nil { - t.Fatal(err) - } - if pkg.Name == "" { - t.Fatal("invalid package") - } - } - }) - } - } -} - -func TestTemplateErrorsFollowLanguageAndRetainCause(t *testing.T) { - original := i18n.Active() - t.Cleanup(func() { _ = i18n.Init(original) }) - for _, tc := range []struct{ locale, want string }{{"en-US", "Unsupported template"}, {"zh-CN", "不支持的模板"}} { - if err := i18n.Init(tc.locale); err != nil { - t.Fatal(err) - } - _, err := prepare(fstest.MapFS{"template.json": {Data: []byte(`{"schemaVersion":2}`)}}, nil) - if err == nil || !strings.Contains(err.Error(), tc.want) { - t.Fatalf("%s: %v", tc.locale, err) - } - cause := os.ErrPermission - wrapped := i18n.Errorf("template.prepare_failed", "go-api", cause) - if !errors.Is(wrapped, cause) { - t.Fatal("lost error chain") - } - } -} - -func TestPublishRollsBackFailedFileWrites(t *testing.T) { - root := t.TempDir() - err := publish(root, map[string][]byte{ - "a": []byte("first"), - "z": []byte("file blocks the following directory"), - "z/child": []byte("cannot create"), - }) - if err == nil { - t.Fatal("expected write failure") - } - entries, readErr := os.ReadDir(root) - if readErr != nil || len(entries) != 0 { - t.Fatalf("partial files remain: %v %v", entries, readErr) - } -} diff --git a/packages/cli/internal/core/template/resolve_test.go b/packages/cli/internal/core/template/resolve_test.go deleted file mode 100644 index bb0a6ab0..00000000 --- a/packages/cli/internal/core/template/resolve_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package template - -import ( - "strings" - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" -) - -func TestResolveRetiredTemplates(t *testing.T) { - original := i18n.Active() - t.Cleanup(func() { _ = i18n.Init(original) }) - r := &Registry{Templates: []Template{{ID: "nextjs-site"}, {ID: "fumadocs-docs"}}} - for _, locale := range []string{"zh-CN", "en-US"} { - _ = i18n.Init(locale) - for old, replacement := range map[string]string{"astro-site": "nextjs-site", "starlight-docs": "fumadocs-docs"} { - entry, err := r.Resolve(old) - if entry != nil || err == nil || !strings.Contains(err.Error(), i18n.Tf("template.retired", old, replacement)) { - t.Fatalf("Resolve(%s) = %v, %v", old, entry, err) - } - } - } - // External registries may still provide their own entry under the old ID. - r.Templates = append(r.Templates, Template{ID: "astro-site", Repo: "https://example.com/astro"}) - if entry, err := r.Resolve("astro-site"); err != nil || entry.Repo != "https://example.com/astro" { - t.Fatalf("explicit entry was overridden: %v, %v", entry, err) - } - if _, err := r.Resolve("unknown"); err == nil || strings.Contains(err.Error(), "nextjs-site") { - t.Fatalf("unknown ID should retain ordinary missing-template error: %v", err) - } -} diff --git a/packages/cli/internal/core/workspace/dashboard_dev_fixture_test.go b/packages/cli/internal/core/workspace/dashboard_dev_fixture_test.go deleted file mode 100644 index 623800d3..00000000 --- a/packages/cli/internal/core/workspace/dashboard_dev_fixture_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package workspace - -import ( - "path/filepath" - "reflect" - "testing" -) - -func TestDashboardDevelopmentFixture(t *testing.T) { - root := filepath.Join("..", "..", "..", "testdata", "dashboard-dev-workspace") - manifest, err := ReadManifest(root) - if err != nil { - t.Fatalf("ReadManifest(%q): %v", root, err) - } - if manifest.Version != ManifestVersion { - t.Fatalf("manifest version = %d, want %d", manifest.Version, ManifestVersion) - } - if manifest.Workspace == nil || manifest.Workspace.ID != "one-dashboard-dev" { - t.Fatalf("workspace identity = %#v", manifest.Workspace) - } - if !reflect.DeepEqual(EnvironmentNames(manifest), []string{"dev", "staging", "prod"}) { - t.Fatal(EnvironmentNames(manifest)) - } - if EnvBackend(manifest) != "" { - t.Fatal("development fixture should not bind a real remote project") - } - - projects := make(map[string]*ManifestProject, len(manifest.Projects)) - kinds := make(map[string]bool) - for index := range manifest.Projects { - project := &manifest.Projects[index] - projects[project.Name] = project - kinds[projectKindFromDir(project.RelativeDir)] = true - } - if !reflect.DeepEqual(kinds, map[string]bool{ - ProjectKindApp: true, ProjectKindService: true, ProjectKindPackage: true, - }) { - t.Fatalf("project kinds = %#v", kinds) - } - for _, name := range []string{"web", "api", "docs", "shared"} { - if projects[name] == nil { - t.Fatalf("project %q is missing from fixture", name) - } - } -} diff --git a/packages/cli/internal/core/workspace/environment_defaults_test.go b/packages/cli/internal/core/workspace/environment_defaults_test.go deleted file mode 100644 index 0e2b5629..00000000 --- a/packages/cli/internal/core/workspace/environment_defaults_test.go +++ /dev/null @@ -1,12 +0,0 @@ -package workspace - -import ( - "reflect" - "testing" -) - -func TestDefaultInfisicalEnvironments(t *testing.T) { - if !reflect.DeepEqual(DefaultEnvironments, []string{"dev", "staging", "prod"}) { - t.Fatal(DefaultEnvironments) - } -} diff --git a/packages/cli/internal/core/workspace/groups_test.go b/packages/cli/internal/core/workspace/groups_test.go deleted file mode 100644 index e9322f09..00000000 --- a/packages/cli/internal/core/workspace/groups_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package workspace - -import ( - "bytes" - "strings" - "testing" -) - -func TestManifestGroupsRoundTripAndPreserveComments(t *testing.T) { - raw := []byte("version = 2\n# keep workspace notes\n[projects.desktop-renderer]\npath='apps/desktop-renderer'\ntoolchain='node'\n[projects.desktop-main]\npath='services/desktop-main'\ntoolchain='node'\n[groups.desktop]\nprojects=['desktop-renderer','desktop-main']\n") - m, err := ParseManifest(raw) - if err != nil { - t.Fatal(err) - } - if len(m.Groups) != 1 || m.Groups[0].Name != "desktop" || len(m.Groups[0].Projects) != 2 { - t.Fatalf("groups: %#v", m.Groups) - } - after, err := MarshalManifest(m) - if err != nil { - t.Fatal(err) - } - if !bytes.Contains(after, []byte("# keep workspace notes")) { - t.Fatal("lost comments") - } - again, err := ParseManifest(after) - if err != nil || len(again.Groups) != 1 { - t.Fatalf("round trip: %s %v", after, err) - } -} - -func TestManifestRejectsInvalidGroups(t *testing.T) { - project := "version=2\n[projects.web]\npath='apps/web'\ntoolchain='node'\n" - for _, group := range []string{"[groups.web]\nprojects=['web']", "[groups.desktop]\nprojects=[]", "[groups.desktop]\nprojects=['missing']", "[groups.desktop]\nprojects=['web','web']", "[groups.'../desktop']\nprojects=['web']"} { - if _, err := ParseManifest([]byte(project + group + "\n")); err == nil { - t.Fatalf("accepted %s", group) - } - } - m, err := ParseManifest([]byte(project + "[groups.desktop]\nprojects=['web']\n")) - if err != nil { - t.Fatal(err) - } - m.Projects = nil - if _, err = MarshalManifest(m); err == nil || !strings.Contains(err.Error(), "web") { - t.Fatalf("accepted orphan member: %v", err) - } -} diff --git a/packages/cli/internal/core/workspace/manifest_atomic_test.go b/packages/cli/internal/core/workspace/manifest_atomic_test.go deleted file mode 100644 index 92962e5c..00000000 --- a/packages/cli/internal/core/workspace/manifest_atomic_test.go +++ /dev/null @@ -1,192 +0,0 @@ -package workspace - -import ( - "errors" - "fmt" - "os" - "path/filepath" - "runtime" - "strings" - "sync" - "testing" -) - -func TestWriteManifestAtomicallyPreservesPermissionsAndFormat(t *testing.T) { - root := t.TempDir() - path := ManifestPath(root) - if err := os.WriteFile(path, []byte(`version = 2 -`+"\n"), 0o600); err != nil { - t.Fatal(err) - } - manifest := &Manifest{ - Version: ManifestVersion, - Projects: []ManifestProject{{ - Name: "web", RelativeDir: "apps/web", Toolchain: "node", - }}, - } - if err := WriteManifest(root, manifest); err != nil { - t.Fatal(err) - } - info, err := os.Stat(path) - if err != nil { - t.Fatal(err) - } - if got := info.Mode().Perm(); runtime.GOOS != "windows" && got != 0o600 { - t.Fatalf("manifest permissions = %o, want 600", got) - } - raw, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if !strings.HasSuffix(string(raw), "\n") || !strings.Contains(string(raw), `[projects.web]`) { - t.Fatalf("manifest formatting changed: %s", raw) - } - assertNoManifestTemps(t, root) -} - -func TestAtomicManifestRenameFailurePreservesPublishedFile(t *testing.T) { - root := t.TempDir() - path := ManifestPath(root) - original := []byte(`version = 2 -` + "\n") - if err := os.WriteFile(path, original, 0o640); err != nil { - t.Fatal(err) - } - replacement := []byte(`version = 2 - -[projects."web"] -path = "apps/web" -toolchain = "node" -` + "\n") - publishErr := errors.New("injected rename failure") - renameCalled := false - err := atomicWriteManifestWithRename(path, replacement, 0o644, func(tempPath, targetPath string) error { - renameCalled = true - if filepath.Dir(tempPath) != filepath.Dir(targetPath) { - t.Fatalf("temp and target are not siblings: %q, %q", tempPath, targetPath) - } - if targetPath != path { - t.Fatalf("rename target = %q, want %q", targetPath, path) - } - raw, readErr := os.ReadFile(tempPath) - if readErr != nil { - t.Fatalf("temp file was not readable after close: %v", readErr) - } - if string(raw) != string(replacement) { - t.Fatalf("temp contents = %q, want %q", raw, replacement) - } - info, statErr := os.Stat(tempPath) - if statErr != nil { - t.Fatal(statErr) - } - if got := info.Mode().Perm(); runtime.GOOS != "windows" && got != 0o640 { - t.Fatalf("temp permissions = %o, want existing mode 640", got) - } - return publishErr - }) - if !renameCalled || !errors.Is(err, publishErr) { - t.Fatalf("atomic write error = %v; renameCalled = %v", err, renameCalled) - } - raw, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if string(raw) != string(original) { - t.Fatalf("failed publication changed existing manifest: %q", raw) - } - assertNoManifestTemps(t, root) -} - -func TestMalformedEnvironmentConfigPreservesPublishedFile(t *testing.T) { - root := t.TempDir() - path := ManifestPath(root) - original := []byte(`version = 2 -` + "\n") - if err := os.WriteFile(path, original, 0o644); err != nil { - t.Fatal(err) - } - if err := InitWorkspaceEnv(root, EnvInit{Kind: EnvBackendInfisical, ConfigJSON: []byte(`{"broken"`)}); err == nil { - t.Fatal("expected malformed environment configuration to fail") - } - raw, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if string(raw) != string(original) { - t.Fatalf("marshal failure changed existing manifest: %q", raw) - } - assertNoManifestTemps(t, root) -} - -func TestAtomicManifestConcurrentReadersNeverObservePartialTOML(t *testing.T) { - root := t.TempDir() - if err := WriteManifest(root, &Manifest{Version: ManifestVersion, Projects: []ManifestProject{}}); err != nil { - t.Fatal(err) - } - const readerCount = 4 - const writes = 20 - const projectsPerWrite = 80 - stop := make(chan struct{}) - errorsFound := make(chan error, 1024) - recordError := func(err error) { - select { - case errorsFound <- err: - default: - } - } - var readers sync.WaitGroup - for reader := 0; reader < readerCount; reader++ { - readers.Add(1) - go func(reader int) { - defer readers.Done() - for { - select { - case <-stop: - return - default: - } - manifest, err := ReadManifest(root) - if err != nil { - recordError(fmt.Errorf("reader %d: %w", reader, err)) - continue - } - if count := len(manifest.Projects); count != 0 && count != projectsPerWrite { - recordError(fmt.Errorf("reader %d observed %d projects", reader, count)) - } - } - }(reader) - } - for iteration := 0; iteration < writes; iteration++ { - projects := make([]ManifestProject, 0, projectsPerWrite) - for index := 0; index < projectsPerWrite; index++ { - projects = append(projects, ManifestProject{ - Name: fmt.Sprintf("service-%03d", index), - RelativeDir: fmt.Sprintf("services/service-%03d", index), - Toolchain: "go", - }) - } - if err := WriteManifest(root, &Manifest{Version: ManifestVersion, Projects: projects}); err != nil { - close(stop) - readers.Wait() - t.Fatal(err) - } - } - close(stop) - readers.Wait() - close(errorsFound) - for err := range errorsFound { - t.Error(err) - } - assertNoManifestTemps(t, root) -} - -func assertNoManifestTemps(t *testing.T, root string) { - t.Helper() - matches, err := filepath.Glob(filepath.Join(root, "."+ManifestFilename+"-*.tmp")) - if err != nil { - t.Fatal(err) - } - if len(matches) != 0 { - t.Fatalf("manifest temp files were not cleaned up: %v", matches) - } -} diff --git a/packages/cli/internal/core/workspace/manifest_roundtrip_test.go b/packages/cli/internal/core/workspace/manifest_roundtrip_test.go deleted file mode 100644 index 6589b8f2..00000000 --- a/packages/cli/internal/core/workspace/manifest_roundtrip_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package workspace - -import ( - "bytes" - "os" - "strings" - "testing" -) - -func TestManifestReadMissing(t *testing.T) { - m, err := ReadManifest(t.TempDir()) - if err != nil || m.Version != 2 || len(m.Projects) != 0 { - t.Fatalf("%+v %v", m, err) - } -} -func TestManifestEditsPreserveUserText(t *testing.T) { - root := t.TempDir() - original := []byte("# 用户说明\nversion = 2 # version\n\n[projects.web] # app\npath = \"apps/web\" # folder\ntoolchain = 'node'\n\n[workspace]\nid = 'demo'\nname = 'Old' # display\n") - if err := os.WriteFile(ManifestPath(root), original, 0600); err != nil { - t.Fatal(err) - } - m, err := ReadManifest(root) - if err != nil { - t.Fatal(err) - } - m.Workspace.Name = "New" - m.Projects = append(m.Projects, ManifestProject{Name: "api", RelativeDir: "services/api", Toolchain: "go"}) - m.Env = &EnvironmentConfig{ProjectID: "remote", Environments: []string{"dev", "staging"}} - if err := WriteManifest(root, m); err != nil { - t.Fatal(err) - } - raw, _ := os.ReadFile(ManifestPath(root)) - for _, keep := range []string{"# 用户说明", "version = 2 # version", "[projects.web] # app", `path = "apps/web" # folder`, "toolchain = 'node'", "# display"} { - if !bytes.Contains(raw, []byte(keep)) { - t.Fatalf("lost user text %q: %s", keep, raw) - } - } - m, err = ReadManifest(root) - if err != nil { - t.Fatal(err) - } - m.Env = nil - m.Projects = m.Projects[:1] - if err := WriteManifest(root, m); err != nil { - t.Fatal(err) - } - raw, _ = os.ReadFile(ManifestPath(root)) - if strings.Contains(string(raw), "env.infisical") || strings.Contains(string(raw), "projects.api") { - t.Fatalf("obsolete table retained: %s", raw) - } - if _, err = ReadManifest(root); err != nil { - t.Fatal(err) - } -} -func TestInitBindingPreservesProjects(t *testing.T) { - root := t.TempDir() - m := &Manifest{Version: 2, Projects: []ManifestProject{{Name: "api", RelativeDir: "services/api", Toolchain: "go"}}} - if err := WriteManifest(root, m); err != nil { - t.Fatal(err) - } - if err := InitWorkspaceEnv(root, EnvInit{Kind: EnvBackendInfisical, ConfigJSON: []byte(`{"projectId":"remote"}`), EnvironmentNames: []string{"dev", "prod"}}); err != nil { - t.Fatal(err) - } - got, err := ReadManifest(root) - if err != nil || got.Env.ProjectID != "remote" || len(got.Projects) != 1 { - t.Fatalf("%+v %v", got, err) - } -} diff --git a/packages/cli/internal/core/workspace/manifest_test.go b/packages/cli/internal/core/workspace/manifest_test.go deleted file mode 100644 index 0f15206a..00000000 --- a/packages/cli/internal/core/workspace/manifest_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package workspace - -import ( - "bytes" - "errors" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/pelletier/go-toml/v2" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" -) - -func TestManifestV2RejectsUnknownAndRemovedFields(t *testing.T) { - for _, raw := range []string{ - "version = 2\n[projects.web]\npath='apps/web'\ntoolchain='node'\ntemplate='react-spa'\n", - `{"version":2,"projects":[]}`, "version = 1\n", "version = 3\n", - "version = 2\n[env]\nenvironments = ['dev']\n", - "version = 2\n[env.infisical]\nprojectId='p'\nenvironments=['dev']\ndefault='dev'\n", - "version = 2\n[env.infisical]\nprojectId='p'\nenvironments=['dev']\nrootPath='/'\n", - "version = 2\n[env.infisical]\nprojectId='p'\nenvironments=['dev']\nprojectName='Demo'\n", - "version = 2\n[env.infisical]\nprojectId='p'\nenvironments=['dev']\nkeys=['TOKEN']\n", - "version = 2\n[env.infisical]\nprojectId='p'\nenvironments=['prod']\n", - "version = 2\n[env.infisical]\nprojectId='p'\nenvironments=['dev','dev']\n", - "version = 2\n[projects.web]\npath='../web'\ntoolchain='node'\n", - "version = 2\n[projects.web]\npath='apps/web'\ntoolchain='unknown'\n", - "version = 2\n[projects.web]\npath='apps/web'\n", - "version = 2\n[projects.web]\npath='apps/web'\ntoolchain='node'\nservice.url='http://localhost'\n", - "version = 2\n[projects.web]\npath='apps/web'\ntoolchain='node'\nenv.enabled=false\n", - "version = 2\n[projects.web]\npath='apps/web'\ntoolchain='node'\npackageManager='pnpm'\n", - } { - if _, err := ParseManifest([]byte(raw)); err == nil { - t.Errorf("accepted invalid document: %s", raw) - } - } -} - -func TestManifestV2RoundTripAndNoGeneratedComments(t *testing.T) { - root := t.TempDir() - m := &Manifest{Version: ManifestVersion, Workspace: &ManifestWorkspace{ID: "demo", Name: "示例"}, Env: &EnvironmentConfig{ProjectID: "remote", Environments: []string{"prod", "dev", "staging"}}, Projects: []ManifestProject{ - {Name: "api", RelativeDir: "services/api", Toolchain: "go"}, - {Name: "web", RelativeDir: "apps/web", Toolchain: "node"}, - }} - if err := WriteManifest(root, m); err != nil { - t.Fatal(err) - } - raw, _ := os.ReadFile(ManifestPath(root)) - for _, removed := range []string{"#", "relativeDir", "template", "templateId", "buildVersion", "packageManager", "default =", "[env]", "[projects]"} { - if bytes.Contains(raw, []byte(removed)) { - t.Errorf("generated unwanted %q: %s", removed, raw) - } - } - if !bytes.Contains(raw, []byte("[env.infisical]")) || !bytes.Contains(raw, []byte("[projects.web]")) { - t.Fatalf("unexpected TOML: %s", raw) - } - got, rev, err := ReadManifestSnapshot(root) - if err != nil { - t.Fatal(err) - } - if got.Workspace.Name != "示例" || got.Env.ProjectID != "remote" || got.Projects[0].Name != "web" || rev == "" { - t.Fatalf("bad roundtrip: %+v", got) - } - if err := WriteManifest(root, got); err != nil { - t.Fatal(err) - } - after, _ := os.ReadFile(ManifestPath(root)) - if !bytes.Equal(raw, after) { - t.Fatalf("unchanged write reformatted file: %s", after) - } -} - -func TestManifestParseErrorIncludesPathLocationAndCause(t *testing.T) { - t.Cleanup(func() { _ = i18n.Init(i18n.DefaultLocale) }) - for _, locale := range []string{"zh-CN", "en-US"} { - _ = i18n.Init(locale) - root := t.TempDir() - path := ManifestPath(root) - if err := os.WriteFile(path, []byte("version = 2\n[workspace\n"), 0644); err != nil { - t.Fatal(err) - } - _, err := ReadManifest(root) - var coded *output.Error - var syntax *toml.DecodeError - if !errors.As(err, &coded) || coded.Code != "MANIFEST_INVALID" || !errors.As(err, &syntax) { - t.Fatalf("lost cause: %v", err) - } - if !strings.Contains(err.Error(), path) || coded.Context["line"] != 2 { - t.Fatalf("missing location: %v %+v", err, coded.Context) - } - } -} -func TestManifestReadErrorIncludesPathAndCause(t *testing.T) { - root := t.TempDir() - if err := os.Mkdir(ManifestPath(root), 0755); err != nil { - t.Fatal(err) - } - _, err := ReadManifest(root) - var pathErr *os.PathError - if !errors.As(err, &pathErr) || !strings.Contains(err.Error(), ManifestPath(root)) { - t.Fatal(err) - } -} -func TestManifestOnlyDiscoversTOML(t *testing.T) { - root := t.TempDir() - _ = os.WriteFile(filepath.Join(root, "one.manifest.json"), []byte(`{"version":1}`), 0644) - if HasManifest(root) { - t.Fatal("legacy JSON is still a workspace marker") - } -} diff --git a/packages/cli/internal/core/workspace/manifest_workspace_identity_test.go b/packages/cli/internal/core/workspace/manifest_workspace_identity_test.go deleted file mode 100644 index bac0f87f..00000000 --- a/packages/cli/internal/core/workspace/manifest_workspace_identity_test.go +++ /dev/null @@ -1,119 +0,0 @@ -package workspace_test - -import ( - "os" - "path/filepath" - "testing" - - "github.com/pelletier/go-toml/v2" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" -) - -// TestReadManifest_WithoutWorkspaceField confirms that a current-version -// manifest without the optional workspace block still parses cleanly. -// New scaffolds always set workspace, but env init may run on workspaces -// that were created before the field existed. -func TestReadManifest_WithoutWorkspaceField(t *testing.T) { - tmp := t.TempDir() - bare := `version = 2 -` - if err := os.WriteFile(filepath.Join(tmp, "one.manifest.toml"), []byte(bare), 0o644); err != nil { - t.Fatal(err) - } - m, err := workspace.ReadManifest(tmp) - if err != nil { - t.Fatalf("ReadManifest = %v", err) - } - if m.Workspace != nil { - t.Errorf("manifest without workspace should round-trip with nil Workspace; got %+v", m.Workspace) - } -} - -func TestSetManifestWorkspaceIdentity_NewWorkspace(t *testing.T) { - tmp := t.TempDir() - if err := workspace.SetManifestWorkspaceIdentity(tmp, "demo-abc123", "demo"); err != nil { - t.Fatalf("SetManifestWorkspaceIdentity = %v", err) - } - m, err := workspace.ReadManifest(tmp) - if err != nil { - t.Fatalf("ReadManifest = %v", err) - } - if m.Workspace == nil { - t.Fatalf("Workspace not set after SetManifestWorkspaceIdentity") - } - if m.Workspace.ID != "demo-abc123" || m.Workspace.Name != "demo" { - t.Errorf("workspace mismatch: got %+v", *m.Workspace) - } -} - -func TestSetManifestWorkspaceIdentity_OverwritesPrevious(t *testing.T) { - tmp := t.TempDir() - if err := workspace.SetManifestWorkspaceIdentity(tmp, "old-id", "old"); err != nil { - t.Fatal(err) - } - if err := workspace.SetManifestWorkspaceIdentity(tmp, "new-id", "new"); err != nil { - t.Fatal(err) - } - m, err := workspace.ReadManifest(tmp) - if err != nil { - t.Fatal(err) - } - if m.Workspace.ID != "new-id" || m.Workspace.Name != "new" { - t.Errorf("expected overwrite to new; got %+v", *m.Workspace) - } -} - -func TestWriteManifest_PreservesWorkspace(t *testing.T) { - tmp := t.TempDir() - if err := workspace.SetManifestWorkspaceIdentity(tmp, "demo-abc", "demo"); err != nil { - t.Fatal(err) - } - before, err := workspace.ReadManifest(tmp) - if err != nil { - t.Fatal(err) - } - before.Projects = []workspace.ManifestProject{ - {Name: "api", RelativeDir: "services/api", Toolchain: "go"}, - } - if err := workspace.WriteManifest(tmp, before); err != nil { - t.Fatalf("WriteManifest = %v", err) - } - - m, err := workspace.ReadManifest(tmp) - if err != nil { - t.Fatal(err) - } - if m.Workspace == nil { - t.Fatalf("WriteManifest dropped Workspace") - } - if m.Workspace.ID != "demo-abc" || m.Workspace.Name != "demo" { - t.Errorf("workspace mutated by write: %+v", *m.Workspace) - } - if len(m.Projects) != 1 || m.Projects[0].Name != "api" { - t.Errorf("projects not written: %+v", m.Projects) - } -} - -// TestWorkspaceField_OmitemptyOnDisk confirms the JSON encoder elides -// the Workspace field when nil so we don't introduce churn on every -// WriteManifest of a workspace-less manifest. -func TestWorkspaceField_OmitemptyOnDisk(t *testing.T) { - tmp := t.TempDir() - if err := workspace.WriteManifest(tmp, &workspace.Manifest{ - Version: workspace.ManifestVersion, - Projects: []workspace.ManifestProject{}, - }); err != nil { - t.Fatal(err) - } - raw, err := os.ReadFile(filepath.Join(tmp, "one.manifest.toml")) - if err != nil { - t.Fatal(err) - } - var doc map[string]any - if err := toml.Unmarshal(raw, &doc); err != nil { - t.Fatal(err) - } - if _, has := doc["workspace"]; has { - t.Errorf("expected workspace key omitted when nil; got=%v", doc) - } -} diff --git a/packages/cli/internal/core/workspace/node_members_test.go b/packages/cli/internal/core/workspace/node_members_test.go deleted file mode 100644 index dd77d6d6..00000000 --- a/packages/cli/internal/core/workspace/node_members_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package workspace - -import ( - "os" - "path/filepath" - "reflect" - "testing" -) - -func writeNodeMember(t *testing.T, root, rel, content string) { - t.Helper() - file := filepath.Join(root, rel) - if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(file, []byte(content), 0o644); err != nil { - t.Fatal(err) - } -} - -func TestNodeMembersRespectDeclarationsAndExclusions(t *testing.T) { - root := t.TempDir() - writeNodeMember(t, root, "apps/desktop/package.json", `{"workspaces":{"packages":["apps/*","packages/*","!apps/unused"]}}`) - for _, dir := range []string{"apps/ui", "apps/main", "apps/unused", "packages/preload", "fixtures/ignored", "node_modules/ignored"} { - writeNodeMember(t, root, "apps/desktop/"+dir+"/package.json", `{}`) - } - dirs, err := NodeProjectPackageDirs(root, "apps/desktop", nil) - if err != nil { - t.Fatal(err) - } - want := []string{"apps/desktop", "apps/desktop/apps/main", "apps/desktop/apps/ui", "apps/desktop/packages/preload"} - if !reflect.DeepEqual(dirs, want) { - t.Fatalf("members=%v", dirs) - } -} - -func TestNodeMembersRejectEscapesAndSymlinks(t *testing.T) { - root := t.TempDir() - for _, pattern := range []string{"../outside", "/tmp/outside", "apps/../../outside", "node_modules/*", "apps/**", "[invalid"} { - t.Run(pattern, func(t *testing.T) { - writeNodeMember(t, root, "apps/desktop/package.json", `{"workspaces":["`+pattern+`"]}`) - if _, err := NodeProjectPackageDirs(root, "apps/desktop", nil); err == nil { - t.Fatal("invalid pattern accepted") - } - }) - } - outside := t.TempDir() - writeNodeMember(t, outside, "package.json", `{}`) - writeNodeMember(t, root, "apps/desktop/package.json", `{"workspaces":["linked"]}`) - if err := os.Symlink(outside, filepath.Join(root, "apps/desktop/linked")); err != nil { - t.Skipf("symlink unavailable: %v", err) - } - if _, err := NodeProjectPackageDirs(root, "apps/desktop", nil); err == nil { - t.Fatal("external symlink accepted") - } -} - -func TestCompositeDependencyStatusIncludesInternalPackages(t *testing.T) { - root := t.TempDir() - writeNodeMember(t, root, "apps/desktop/package.json", `{"workspaces":["apps/ui"]}`) - writeNodeMember(t, root, "apps/desktop/apps/ui/package.json", `{"dependencies":{"renderer":"1.0.0"}}`) - project := filepath.Join(root, "apps/desktop") - if ProjectDependenciesInstalled(root, project, "node") { - t.Fatal("missing internal dependencies reported ready") - } - writeNodeMember(t, root, "apps/desktop/apps/ui/node_modules/renderer/package.json", `{}`) - if !ProjectDependenciesInstalled(root, project, "node") { - t.Fatal("installed internal dependency reported missing") - } -} diff --git a/packages/cli/internal/core/workspace/overview_test.go b/packages/cli/internal/core/workspace/overview_test.go deleted file mode 100644 index 0918d87f..00000000 --- a/packages/cli/internal/core/workspace/overview_test.go +++ /dev/null @@ -1,154 +0,0 @@ -package workspace - -import ( - "os" - "path/filepath" - "testing" -) - -func withIsolatedOverviewProfiles(t *testing.T) { - t.Helper() - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) -} - -func TestBuildOverview_EmptyRoot(t *testing.T) { - ov, err := BuildOverview("") - if err != nil { - t.Fatalf("BuildOverview(\"\"): %v", err) - } - if ov.Present { - t.Errorf("present = true; want false for empty root") - } - if ov.Schema != OverviewSchema { - t.Errorf("schema = %q; want %q", ov.Schema, OverviewSchema) - } -} - -func TestBuildOverview_NoManifestAtRoot(t *testing.T) { - withIsolatedOverviewProfiles(t) - tmp := t.TempDir() - ov, err := BuildOverview(tmp) - if err != nil { - t.Fatalf("BuildOverview: %v", err) - } - if ov.Present { - t.Errorf("present = true; want false (no manifest)") - } -} - -// fully populated workspace should land with zero issues — clean state is -// the case where users add things, so it has to look quiet. -func TestBuildOverview_FullyConfigured_NoIssues(t *testing.T) { - withIsolatedOverviewProfiles(t) - tmp := t.TempDir() - m := &Manifest{ - Version: ManifestVersion, - Workspace: &ManifestWorkspace{ID: "demo", Name: "demo"}, - - Env: &EnvironmentConfig{ProjectID: "remote", Environments: []string{"dev", "prod"}}, - - Projects: []ManifestProject{ - { - Name: "web", RelativeDir: "apps/web", Toolchain: "node", - }, - }, - } - if err := WriteManifest(tmp, m); err != nil { - t.Fatalf("WriteManifest: %v", err) - } - ov, err := BuildOverview(tmp) - if err != nil { - t.Fatalf("BuildOverview: %v", err) - } - if !ov.Present { - t.Fatalf("present = false; want true") - } - if len(ov.Issues) != 0 { - t.Errorf("workspace issues = %+v; want none", ov.Issues) - } - if got := len(ov.Projects); got != 1 { - t.Fatalf("projects = %d; want 1", got) - } - if iss := ov.Projects[0].Issues; len(iss) != 0 { - t.Errorf("project issues = %+v; want none", iss) - } - if ov.Projects[0].Kind != ProjectKindApp { - t.Errorf("kind = %q; want %q", ov.Projects[0].Kind, ProjectKindApp) - } - if ov.Workspace.Domains["env"] != EnvBackendInfisical { - t.Errorf("workspace env domain = %q", ov.Workspace.Domains["env"]) - } - if len(ov.Projects[0].Domains) != 1 || ov.Projects[0].Domains["env"] != EnvBackendInfisical { - t.Errorf("project domains = %#v; want only infisical", ov.Projects[0].Domains) - } -} - -// Missing workspace env backend → single workspace-level issue, not -// duplicated per project. -func TestBuildOverview_EnvWorkspaceLevelOnly(t *testing.T) { - withIsolatedOverviewProfiles(t) - tmp := t.TempDir() - m := &Manifest{ - Version: ManifestVersion, - Workspace: &ManifestWorkspace{ID: "demo", Name: "demo"}, - Projects: []ManifestProject{ - {Name: "web", RelativeDir: "apps/web", Toolchain: "node"}, - }, - } - if err := WriteManifest(tmp, m); err != nil { - t.Fatalf("WriteManifest: %v", err) - } - ov, err := BuildOverview(tmp) - if err != nil { - t.Fatalf("BuildOverview: %v", err) - } - if len(ov.Issues) != 1 || ov.Issues[0].Domain != IssueDomainEnv { - t.Fatalf("workspace issues = %+v; want one env issue", ov.Issues) - } - for _, iss := range ov.Projects[0].Issues { - if iss.Domain == IssueDomainEnv { - t.Errorf("env reported as per-project issue too: %+v", iss) - } - } -} - -// packages projects must not contribute container/deploy noise. -func TestBuildOverview_PackagesSkipDomainChecks(t *testing.T) { - withIsolatedOverviewProfiles(t) - tmp := t.TempDir() - m := &Manifest{ - Version: ManifestVersion, - Workspace: &ManifestWorkspace{ID: "demo", Name: "demo"}, - - Env: &EnvironmentConfig{ProjectID: "remote", Environments: []string{"dev", "staging", "prod"}}, - - Projects: []ManifestProject{ - {Name: "utils", RelativeDir: "packages/utils", Toolchain: "node"}, - }, - } - if err := WriteManifest(tmp, m); err != nil { - t.Fatalf("WriteManifest: %v", err) - } - ov, err := BuildOverview(tmp) - if err != nil { - t.Fatalf("BuildOverview: %v", err) - } - p := ov.Projects[0] - if p.Kind != ProjectKindPackage { - t.Errorf("kind = %q; want %q", p.Kind, ProjectKindPackage) - } - if len(p.Issues) != 0 { - t.Errorf("package project flagged with issues: %+v", p.Issues) - } -} - -func TestBuildOverview_RejectsBadManifest(t *testing.T) { - tmp := t.TempDir() - if err := os.WriteFile(filepath.Join(tmp, ManifestFilename), []byte("not json"), 0o644); err != nil { - t.Fatal(err) - } - _, err := BuildOverview(tmp) - if err == nil { - t.Fatal("BuildOverview accepted bogus manifest") - } -} diff --git a/packages/cli/internal/core/workspace/package_manager_test.go b/packages/cli/internal/core/workspace/package_manager_test.go deleted file mode 100644 index 5b17de26..00000000 --- a/packages/cli/internal/core/workspace/package_manager_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package workspace - -import ( - "os" - "path/filepath" - "testing" -) - -func TestResolvePackageManagerPrecedence(t *testing.T) { - root := t.TempDir() - put := func(name, contents string) { - t.Helper() - if err := os.WriteFile(filepath.Join(root, name), []byte(contents), 0644); err != nil { - t.Fatal(err) - } - } - check := func(fallback, want string) { - t.Helper() - got, err := ResolvePackageManager(root, fallback) - if err != nil || got != want { - t.Fatalf("got %q %v, want %q", got, err, want) - } - } - check("", "pnpm") - put("package-lock.json", "{}") - if _, err := ResolvePackageManager(root, ""); err == nil { - t.Fatal("unsupported lockfile accepted") - } - for _, manager := range []string{"npm", "yarn", "bun"} { - put("package.json", `{"packageManager":"pnpm@12.3.4"}`) - if _, err := ResolvePackageManager(root, manager); err == nil { - t.Fatal("unsupported project manager accepted", manager) - } - put("package.json", `{"packageManager":"`+manager+`@1.0.0"}`) - if _, err := ResolvePackageManager(root, "pnpm"); err == nil { - t.Fatal("unsupported workspace manager accepted", manager) - } - } - put("package.json", `{"packageManager":"unknown@1.0.0"}`) - if _, err := ResolvePackageManager(root, ""); err == nil { - t.Fatal("invalid manager accepted") - } - put("package.json", "malformed") - if _, err := ResolvePackageManager(root, ""); err == nil { - t.Fatal("invalid package ignored") - } -} diff --git a/packages/cli/internal/core/workspace/retired_domains_test.go b/packages/cli/internal/core/workspace/retired_domains_test.go deleted file mode 100644 index 9202567a..00000000 --- a/packages/cli/internal/core/workspace/retired_domains_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package workspace - -import ( - "bytes" - "fmt" - "os" - "path/filepath" - "strings" - "testing" -) - -func TestReadManifestRejectsRetiredDomainsWithoutRewritingFiles(t *testing.T) { - for _, domain := range []string{"env", "dev", "deploy", "container"} { - for _, scope := range []string{"workspace", "project"} { - t.Run(scope+"/"+domain, func(t *testing.T) { - root := t.TempDir() - manifest := fmt.Sprintf("version=2\n[domains.%s]\nkind='old'\n", domain) - if scope == "project" { - manifest = fmt.Sprintf("version=2\n[projects.web]\npath='apps/web'\ntoolchain='node'\n[projects.web.domains.%s]\nkind='old'\n", domain) - } - path := filepath.Join(root, ManifestFilename) - before := []byte(manifest) - if err := os.WriteFile(path, before, 0o644); err != nil { - t.Fatal(err) - } - artifact := filepath.Join(root, ".env") - content := []byte("UNCHANGED=local\n") - if err := os.WriteFile(artifact, content, 0o644); err != nil { - t.Fatal(err) - } - _, err := ReadManifest(root) - if err == nil || !strings.Contains(err.Error(), "domains") { - t.Fatalf("error = %v", err) - } - coded, ok := err.(interface{ ErrorCode() string }) - if !ok || coded.ErrorCode() != "MANIFEST_INVALID" { - t.Fatalf("error code = %v", err) - } - for name, want := range map[string][]byte{path: before, artifact: content} { - got, err := os.ReadFile(name) - if err != nil || !bytes.Equal(got, want) { - t.Fatalf("file changed: %s (%v)", name, err) - } - } - }) - } - } -} diff --git a/packages/cli/internal/core/workspace/strings_test.go b/packages/cli/internal/core/workspace/strings_test.go deleted file mode 100644 index c12dc2d2..00000000 --- a/packages/cli/internal/core/workspace/strings_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package workspace_test - -import ( - "regexp" - "strings" - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" -) - -func TestGenerateProjectID_Shape(t *testing.T) { - cases := []struct { - in string - prefix string - }{ - {"demo", "demo"}, - {"My App", "my-app"}, - {" spaced ", "spaced"}, - {"camelCase", "camel-case"}, - {"with-hyphens-already", "with-hyphens-already"}, - } - idRE := regexp.MustCompile(`^[a-z0-9-]+$`) - for _, tc := range cases { - t.Run(tc.in, func(t *testing.T) { - got := workspace.GenerateProjectID(tc.in) - if !strings.HasPrefix(got, tc.prefix+"-") { - t.Errorf("GenerateProjectID(%q) = %q; want prefix %q-", tc.in, got, tc.prefix) - } - suffix := strings.TrimPrefix(got, tc.prefix+"-") - if len(suffix) != 6 { - t.Errorf("suffix len = %d; want 6 (got %q)", len(suffix), got) - } - if !idRE.MatchString(got) { - t.Errorf("id %q has chars outside [a-z0-9-]", got) - } - }) - } -} - -// TestGenerateProjectID_EmptyFallback covers the all-separators name edge -// case so the prefix never collapses to an empty string (which would emit -// a leading dash). -func TestGenerateProjectID_EmptyFallback(t *testing.T) { - got := workspace.GenerateProjectID("___") - if !strings.HasPrefix(got, "ws-") { - t.Errorf("GenerateProjectID for blank-after-kebab fell back to %q; want ws- prefix", got) - } -} - -func TestGenerateProjectID_RandomnessAcrossCalls(t *testing.T) { - // Two consecutive calls with the same input must produce different - // suffixes; otherwise the env init retry loop can never escape. - a := workspace.GenerateProjectID("demo") - b := workspace.GenerateProjectID("demo") - if a == b { - t.Errorf("expected distinct ids across calls; got %q twice", a) - } -} diff --git a/packages/cli/internal/core/workspace/summary_test.go b/packages/cli/internal/core/workspace/summary_test.go deleted file mode 100644 index f5e8bb50..00000000 --- a/packages/cli/internal/core/workspace/summary_test.go +++ /dev/null @@ -1,60 +0,0 @@ -package workspace - -import ( - "os" - "path/filepath" - "testing" -) - -func TestProjectDependenciesInstalledChecksDeclaredNodeDependencies(t *testing.T) { - root := t.TempDir() - projectDir := filepath.Join(root, "apps", "web") - if err := os.MkdirAll(filepath.Join(root, "node_modules", "unrelated"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(projectDir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(projectDir, "package.json"), []byte(`{ - "name": "web", - "dependencies": {"react": "latest"}, - "devDependencies": {"vite": "latest"} -}`), 0o644); err != nil { - t.Fatal(err) - } - - if ProjectDependenciesInstalled(root, projectDir, "node") { - t.Fatal("an unrelated root node_modules must not mark a newly added project as installed") - } - if err := os.MkdirAll(filepath.Join(root, "node_modules", "react"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(filepath.Join(projectDir, "node_modules", "vite"), 0o755); err != nil { - t.Fatal(err) - } - if !ProjectDependenciesInstalled(root, projectDir, "node") { - t.Fatal("dependencies resolved across root and project node_modules should count as installed") - } -} - -func TestGoDependenciesRequireRuntimeVerification(t *testing.T) { - root := t.TempDir() - projectDir := filepath.Join(root, "services", "api") - if ProjectDependenciesInstalled(root, projectDir, "go") { - t.Fatal("a static filesystem check cannot claim Go dependencies are installed") - } -} - -func TestProjectDependenciesInstalledAllowsDependencyFreeNodeProject(t *testing.T) { - root := t.TempDir() - projectDir := filepath.Join(root, "apps", "empty") - if err := os.MkdirAll(projectDir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(projectDir, "package.json"), []byte(`{"name":"empty"}`), 0o644); err != nil { - t.Fatal(err) - } - if !ProjectDependenciesInstalled(root, projectDir, "node") { - t.Fatal("a dependency-free Node project does not need an install") - } -} diff --git a/packages/cli/internal/modules/creation/electron_test.go b/packages/cli/internal/modules/creation/electron_test.go deleted file mode 100644 index 9d863366..00000000 --- a/packages/cli/internal/modules/creation/electron_test.go +++ /dev/null @@ -1,381 +0,0 @@ -package creation - -import ( - "context" - "encoding/json" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - - "github.com/pelletier/go-toml/v2" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" - "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/miseconfig" - "gopkg.in/yaml.v3" -) - -func TestElectronProjectsShareRootWorkspace(t *testing.T) { - s := newCreationService(t) - root := filepath.Join(t.TempDir(), "desktop workspace") - if _, err := s.CreateWorkspace(context.Background(), WorkspaceInput{TargetDir: root, Name: "demo"}); err != nil { - t.Fatal(err) - } - for _, name := range []string{"desktop", "electron"} { - if err := addLanguageProject(t, s, root, "electron-app", name); err != nil { - t.Fatal(err) - } - } - manifest, err := workspace.ReadManifest(root) - if err != nil { - t.Fatal(err) - } - if len(manifest.Projects) != 6 || len(manifest.Groups) != 2 { - t.Fatalf("manifest: %+v", manifest) - } - var config struct { - Packages []string `yaml:"packages"` - AllowBuilds map[string]bool `yaml:"allowBuilds"` - } - raw, _ := os.ReadFile(filepath.Join(root, "pnpm-workspace.yaml")) - if err := yaml.Unmarshal(raw, &config); err != nil { - t.Fatal(err) - } - if !config.AllowBuilds["electron"] { - t.Fatal("missing install policy") - } - for _, name := range []string{"desktop", "electron"} { - for _, part := range []struct{ dir, role string }{{"apps", "renderer"}, {"services", "main"}, {"packages", "preload"}} { - rel := part.dir + "/" + name + "-" + part.role - pkg, err := workspace.ReadPackageJSON(filepath.Join(root, rel)) - if err != nil { - t.Fatal(err) - } - if pkg.Name != name+"-"+part.role { - t.Fatalf("package: %+v", pkg) - } - if part.role != "preload" && pkg.Dependencies[name+"-preload"] != "workspace:*" { - t.Fatalf("preload dependency: %+v", pkg) - } - raw, _ := os.ReadFile(filepath.Join(root, rel, "package.json")) - if strings.Contains(string(raw), `"workspaces"`) { - t.Fatal("nested workspace remained") - } - for _, file := range []string{"pnpm-lock.yaml", "pnpm-workspace.yaml", ".npmrc"} { - if _, err := os.Stat(filepath.Join(root, rel, file)); !os.IsNotExist(err) { - t.Fatalf("project-owned %s", file) - } - } - if err := filepath.WalkDir(filepath.Join(root, rel), func(file string, entry os.DirEntry, err error) error { - if err != nil { - return err - } - if entry.IsDir() { - return nil - } - raw, err := os.ReadFile(file) - if err != nil { - return err - } - if strings.Contains(string(raw), "@one-template-electron/") || strings.Contains(string(raw), "{{projectName") || strings.HasSuffix(file, ".hbs") { - t.Errorf("unrendered reference: %s", file) - } - return nil - }); err != nil { - t.Fatal(err) - } - } - if _, err := os.Stat(filepath.Join(root, "apps", name)); !os.IsNotExist(err) { - t.Fatal("created nested project root") - } - } - // Verify regenerated group tasks and packaging edges, without a GUI or network. - plan, err := miseconfig.Build(root, miseconfig.Options{}) - if err != nil { - t.Fatal(err) - } - if len(plan.Changes) != 0 { - t.Fatalf("not idempotent: %+v", plan.Changes) - } - var mise struct { - Tasks map[string]struct { - Depends []string `toml:"depends"` - } `toml:"tasks"` - } - raw, _ = os.ReadFile(filepath.Join(root, "mise.toml")) - if err := toml.Unmarshal(raw, &mise); err != nil { - t.Fatal(err) - } - for _, name := range []string{"desktop", "electron"} { - for _, op := range []string{"dev", "build", "test", "pack"} { - if len(mise.Tasks[name+":"+op].Depends) == 0 { - t.Fatalf("missing %s:%s", name, op) - } - } - for _, role := range []string{"renderer", "main", "preload"} { - want := "//:" + name + "-" + role + ":build" - found := false - for _, dep := range mise.Tasks[name+"-main:pack"].Depends { - found = found || dep == want - } - if !found { - t.Fatalf("missing pack dependency %s", want) - } - } - } -} - -func TestElectronCompositeNamesAndPreflightRollback(t *testing.T) { - for _, name := range []string{"desktop", "electron", "Alpha_Desktop"} { - t.Run(name, func(t *testing.T) { - s := newCreationService(t) - root := filepath.Join(t.TempDir(), "workspace") - if _, err := s.CreateWorkspace(context.Background(), WorkspaceInput{TargetDir: root, Name: "demo"}); err != nil { - t.Fatal(err) - } - if err := addLanguageProject(t, s, root, "electron-app", name); err != nil { - t.Fatal(err) - } - m, err := workspace.ReadManifest(root) - if err != nil { - t.Fatal(err) - } - if len(m.Groups) != 1 || m.Groups[0].Name != name { - t.Fatalf("group: %+v", m.Groups) - } - for _, p := range m.Projects { - if !strings.HasPrefix(p.Name, name+"-") { - t.Fatalf("lost input name: %+v", p) - } - } - }) - } - for _, conflict := range []string{"services/desktop-main/keep.txt", "pnpm-workspace.yaml", "mise.toml"} { - t.Run(conflict, func(t *testing.T) { - s := newCreationService(t) - root := filepath.Join(t.TempDir(), "workspace") - if _, err := s.CreateWorkspace(context.Background(), WorkspaceInput{TargetDir: root, Name: "demo"}); err != nil { - t.Fatal(err) - } - content := "user content" - if conflict == "pnpm-workspace.yaml" { - content = "packages: ['!services/*']\n" - } - if conflict == "mise.toml" { - content = "# one:managed-v1 invalid\n" - } - dest := filepath.Join(root, conflict) - if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(dest, []byte(content), 0o644); err != nil { - t.Fatal(err) - } - before, _ := os.ReadFile(filepath.Join(root, "one.manifest.toml")) - if err := addLanguageProject(t, s, root, "electron-app", "desktop"); err == nil { - t.Fatal("accepted conflict") - } - after, _ := os.ReadFile(filepath.Join(root, "one.manifest.toml")) - if string(before) != string(after) { - t.Fatal("changed manifest") - } - after, _ = os.ReadFile(dest) - if string(after) != content { - t.Fatal("changed user file") - } - for _, rel := range []string{"apps/desktop-renderer", "packages/desktop-preload"} { - if _, err := os.Stat(filepath.Join(root, rel)); !os.IsNotExist(err) { - t.Fatalf("partial output: %s", rel) - } - } - }) - } -} - -func TestElectronNativePackageGraphIsIsolated(t *testing.T) { - pnpm, err := exec.LookPath("pnpm") - if err != nil { - t.Skip("pnpm is not installed") - } - s := newCreationService(t) - root := filepath.Join(t.TempDir(), "workspace with spaces") - if _, err := s.CreateWorkspace(context.Background(), WorkspaceInput{TargetDir: root, Name: "demo"}); err != nil { - t.Fatal(err) - } - for _, name := range []string{"desktop", "studio"} { - if err := addLanguageProject(t, s, root, "electron-app", name); err != nil { - t.Fatal(err) - } - } - home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config")) - t.Setenv("XDG_CACHE_HOME", filepath.Join(home, "cache")) - t.Setenv("CI", "true") - t.Setenv("npm_config_manage_package_manager_versions", "false") - m, err := workspace.ReadManifest(root) - if err != nil { - t.Fatal(err) - } - for _, p := range m.Projects { - file := filepath.Join(root, p.RelativeDir, "package.json") - raw, _ := os.ReadFile(file) - var pkg map[string]any - if err := json.Unmarshal(raw, &pkg); err != nil { - t.Fatal(err) - } - deps := map[string]any{} - for _, field := range []string{"dependencies", "devDependencies"} { - if current, ok := pkg[field].(map[string]any); ok { - for key, value := range current { - if value == "workspace:*" { - deps[key] = value - } - } - } - } - delete(pkg, "devDependencies") - pkg["dependencies"] = deps - delete(pkg, "engines") - delete(pkg, "packageManager") - pkg["scripts"] = map[string]string{"build": `node -e "console.log('MEMBER:` + p.Name + `')"`} - raw, _ = json.MarshalIndent(pkg, "", " ") - if err := os.WriteFile(file, raw, 0o644); err != nil { - t.Fatal(err) - } - } - // This offline graph fixture has no external packages or manager downloads. - rootPackage := filepath.Join(root, "package.json") - raw, _ := os.ReadFile(rootPackage) - var pkg map[string]any - if err := json.Unmarshal(raw, &pkg); err != nil { - t.Fatal(err) - } - delete(pkg, "packageManager") - raw, _ = json.Marshal(pkg) - if err := os.WriteFile(rootPackage, raw, 0o644); err != nil { - t.Fatal(err) - } - install := exec.Command(pnpm, "install", "--offline", "--no-frozen-lockfile") - install.Dir = root - if out, err := install.CombinedOutput(); err != nil { - t.Fatalf("install: %v\n%s", err, out) - } - for _, name := range []string{"desktop", "studio"} { - cmd := exec.Command(pnpm, "--filter", name+"-main...", "run", "build") - cmd.Dir = root - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("build: %v\n%s", err, out) - } - text := string(out) - preload := strings.Index(text, "MEMBER:"+name+"-preload") - renderer := strings.Index(text, "MEMBER:"+name+"-renderer") - main := strings.Index(text, "MEMBER:"+name+"-main") - if preload < 0 || renderer <= preload || main <= renderer { - t.Fatalf("incorrect order: %s", out) - } - other := "desktop" - if name == other { - other = "studio" - } - if strings.Contains(text, "MEMBER:"+other+"-") { - t.Fatalf("started another group: %s", out) - } - } -} - -func TestElectronPreservesRootSettings(t *testing.T) { - for _, policy := range []string{"", "allowBuilds:\n electron: false\n esbuild: true\n", "onlyBuiltDependencies: [esbuild]\n"} { - t.Run(policy, func(t *testing.T) { - s := newCreationService(t) - root := filepath.Join(t.TempDir(), "demo") - if _, err := s.CreateWorkspace(context.Background(), WorkspaceInput{TargetDir: root, Name: "demo"}); err != nil { - t.Fatal(err) - } - before := "# user settings\npackages: [apps/*]\nminimumReleaseAge: 1440\n" + policy - if err := os.WriteFile(filepath.Join(root, "pnpm-workspace.yaml"), []byte(before), 0o644); err != nil { - t.Fatal(err) - } - npmrc := "registry=https://registry.npmjs.org/\n" - if err := os.WriteFile(filepath.Join(root, ".npmrc"), []byte(npmrc), 0o644); err != nil { - t.Fatal(err) - } - if err := addLanguageProject(t, s, root, "electron-app", "desktop"); err != nil { - t.Fatal(err) - } - raw, _ := os.ReadFile(filepath.Join(root, "pnpm-workspace.yaml")) - var cfg map[string]any - if err := yaml.Unmarshal(raw, &cfg); err != nil { - t.Fatal(err) - } - if cfg["minimumReleaseAge"] != 1440 || !strings.Contains(string(raw), "# user settings") { - t.Fatalf("lost config: %s", raw) - } - if policy == "" && cfg["allowBuilds"].(map[string]any)["electron"] != true { - t.Fatal("missing default policy") - } - if strings.HasPrefix(policy, "allowBuilds") && cfg["allowBuilds"].(map[string]any)["electron"] != false { - t.Fatal("overwrote denial") - } - if strings.HasPrefix(policy, "onlyBuilt") && cfg["allowBuilds"] != nil { - t.Fatal("mixed policies") - } - after, _ := os.ReadFile(filepath.Join(root, ".npmrc")) - if string(after) != npmrc { - t.Fatal("changed registry") - } - }) - } -} - -func TestElectronRejectsOtherManagerWithoutPartialWrites(t *testing.T) { - s := newCreationService(t) - root := filepath.Join(t.TempDir(), "demo") - if _, err := s.CreateWorkspace(context.Background(), WorkspaceInput{TargetDir: root, Name: "demo"}); err != nil { - t.Fatal(err) - } - pkg := `{"private":true,"packageManager":"npm@11.0.0"}` - if err := os.WriteFile(filepath.Join(root, "package.json"), []byte(pkg), 0o644); err != nil { - t.Fatal(err) - } - before, _ := os.ReadFile(filepath.Join(root, "one.manifest.toml")) - if err := addLanguageProject(t, s, root, "electron-app", "desktop"); err == nil { - t.Fatal("accepted manager") - } - after, _ := os.ReadFile(filepath.Join(root, "one.manifest.toml")) - if string(before) != string(after) { - t.Fatal("changed manifest") - } - for _, rel := range []string{"apps/desktop-renderer", "services/desktop-main", "packages/desktop-preload"} { - if _, err := os.Stat(filepath.Join(root, rel)); !os.IsNotExist(err) { - t.Fatal("partial output") - } - } - after, _ = os.ReadFile(filepath.Join(root, "package.json")) - if string(after) != pkg { - t.Fatal("changed manager") - } -} - -func TestElectronRejectsNormalizedNameCollision(t *testing.T) { - s := newCreationService(t) - root := filepath.Join(t.TempDir(), "demo") - if _, err := s.CreateWorkspace(context.Background(), WorkspaceInput{TargetDir: root, Name: "demo"}); err != nil { - t.Fatal(err) - } - if err := addLanguageProject(t, s, root, "electron-app", "DesktopApp"); err != nil { - t.Fatal(err) - } - before, _ := os.ReadFile(filepath.Join(root, "pnpm-workspace.yaml")) - if err := addLanguageProject(t, s, root, "electron-app", "desktop-app"); err == nil { - t.Fatal("accepted duplicate package names") - } - after, _ := os.ReadFile(filepath.Join(root, "pnpm-workspace.yaml")) - if string(before) != string(after) { - t.Fatal("changed workspace") - } - if _, err := os.Stat(filepath.Join(root, "apps/desktop-app-renderer")); !os.IsNotExist(err) { - t.Fatal("partial project") - } -} diff --git a/packages/cli/internal/modules/creation/empty_git_test.go b/packages/cli/internal/modules/creation/empty_git_test.go deleted file mode 100644 index 4e04b4b1..00000000 --- a/packages/cli/internal/modules/creation/empty_git_test.go +++ /dev/null @@ -1,132 +0,0 @@ -package creation - -import ( - "context" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" -) - -func runTestGit(t *testing.T, root string, args ...string) string { - t.Helper() - cmd := exec.Command("git", args...) - cmd.Dir = root - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("git %v: %v: %s", args, err, out) - } - return strings.TrimSpace(string(out)) -} - -func isolateGit(t *testing.T) { - t.Helper() - // Commit hooks export paths for the invoking repository. Fixtures and - // linked worktrees must discover their own Git metadata instead. - for _, key := range []string{"GIT_DIR", "GIT_COMMON_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES"} { - t.Setenv(key, "") // Restore the original value when the test finishes. - if err := os.Unsetenv(key); err != nil { - t.Fatal(err) - } - } - t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) - t.Setenv("GIT_CONFIG_NOSYSTEM", "1") -} - -func TestCreateInEmptyGitRepositoryPreservesIdentityAndHooks(t *testing.T) { - isolateGit(t) - root := t.TempDir() - runTestGit(t, root, "init", "-b", "feature") - runTestGit(t, root, "remote", "add", "origin", "https://example.invalid/team/demo.git") - hook := filepath.Join(root, ".git", "hooks", "pre-commit") - if err := os.WriteFile(hook, []byte("#!/bin/sh\n# team hook\n"), 0755); err != nil { - t.Fatal(err) - } - before, _ := os.ReadFile(filepath.Join(root, ".git", "config")) - result, err := newCreationService(t).CreateWorkspace(context.Background(), WorkspaceInput{TargetDir: root, Name: "demo", CreatedInPlace: true}) - if err != nil { - t.Fatal(err) - } - if result.HooksWarn == nil { - t.Fatal("custom hook conflict was not reported") - } - after, _ := os.ReadFile(filepath.Join(root, ".git", "config")) - if string(before) != string(after) { - t.Fatal("Git config changed") - } - if runTestGit(t, root, "symbolic-ref", "--short", "HEAD") != "feature" { - t.Fatal("branch changed") - } - got, _ := os.ReadFile(hook) - if string(got) != "#!/bin/sh\n# team hook\n" { - t.Fatal("custom hook changed") - } -} - -func TestEmptyTargetGitValidation(t *testing.T) { - isolateGit(t) - for _, kind := range []string{"initialized", "cloned", "invalid", "file", "tracked-deletion", "staged-deletion", "sparse-index"} { - t.Run(kind, func(t *testing.T) { - root := t.TempDir() - switch kind { - case "invalid": - if err := os.Mkdir(filepath.Join(root, ".git"), 0755); err != nil { - t.Fatal(err) - } - case "cloned": - origin := t.TempDir() - runTestGit(t, origin, "init", "--bare") - runTestGit(t, root, "clone", origin, ".") - default: - runTestGit(t, root, "init") - if kind != "initialized" { - name := filepath.Join(root, "README.md") - if err := os.WriteFile(name, []byte("keep"), 0644); err != nil { - t.Fatal(err) - } - if kind != "file" { - runTestGit(t, root, "add", "README.md") - runTestGit(t, root, "-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", "-qm", "initial") - if kind == "sparse-index" { - runTestGit(t, root, "update-index", "--skip-worktree", "README.md") - } - if err := os.Remove(name); err != nil { - t.Fatal(err) - } - if kind == "staged-deletion" { - runTestGit(t, root, "add", "-u") - } - } - } - } - err := newCreationService(t).ValidateWorkspaceTarget(root) - allowed := kind == "initialized" || kind == "cloned" - if (err == nil) != allowed { - t.Fatalf("allowed=%v error=%v", allowed, err) - } - if _, err := os.Stat(filepath.Join(root, "one.manifest.toml")); !os.IsNotExist(err) { - t.Fatal("validation wrote files") - } - }) - } -} - -func TestCreateLinkedEmptyWorktreeDoesNotInstallSharedHooks(t *testing.T) { - isolateGit(t) - origin := t.TempDir() - runTestGit(t, origin, "init") - runTestGit(t, origin, "-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", "--allow-empty", "-qm", "empty") - root := filepath.Join(t.TempDir(), "linked") - runTestGit(t, origin, "worktree", "add", "--detach", root) - result, err := newCreationService(t).CreateWorkspace(context.Background(), WorkspaceInput{TargetDir: root, Name: "linked", CreatedInPlace: true}) - if err != nil { - t.Fatal(err) - } - if result.HooksWarn == nil { - t.Fatal("shared hook skip was not reported") - } - if _, err := os.Stat(filepath.Join(origin, ".git", "hooks", "pre-commit")); !os.IsNotExist(err) { - t.Fatal("shared hooks changed") - } -} diff --git a/packages/cli/internal/modules/creation/json_fields_test.go b/packages/cli/internal/modules/creation/json_fields_test.go deleted file mode 100644 index 37178da6..00000000 --- a/packages/cli/internal/modules/creation/json_fields_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package creation - -import ( - "encoding/json" - "testing" -) - -func TestJSONFieldEditsPreserveSurroundingContent(t *testing.T) { - for _, tc := range []struct { - name, before, key, value, after string - }{ - {"remove first", "{\n \"manager\": \"pnpm\",\n \"name\": \"demo\"\n}\n", "manager", "", "{\n \"name\": \"demo\"\n}\n"}, - {"remove last", "{\n\t\"name\": \"demo\",\n\t\"manager\": \"pnpm\"\n}\n", "manager", "", "{\n\t\"name\": \"demo\"\n}\n"}, - {"remove middle", `{"name":"demo","manager":"pnpm","files":["src","dist"]}`, "manager", "", `{"name":"demo","files":["src","dist"]}`}, - {"remove only", "{\n \"manager\": \"pnpm\"\n}\n", "manager", "", "{}\n"}, - {"absent removal", `{"name":"demo"}`, "manager", "", `{"name":"demo"}`}, - {"empty object", "{}\n", "private", "true", "{\"private\": true}\n"}, - {"append with tabs and CRLF", "{\r\n\t\"name\": \"demo\"\r\n}\r\n", "private", "true", "{\r\n\t\"name\": \"demo\",\r\n\t\"private\": true\r\n}\r\n"}, - {"escaped key", `{"na\u006de" : "old", "exports":{".":["a","b"]}}`, "name", `"new"`, `{"na\u006de" : "new", "exports":{".":["a","b"]}}`}, - {"null value", `{"name":"demo","custom":false}`, "custom", "null", `{"name":"demo","custom":null}`}, - } { - t.Run(tc.name, func(t *testing.T) { - var value json.RawMessage - if tc.value != "" { - value = json.RawMessage(tc.value) - } - got, err := updateJSONFields([]byte(tc.before), map[string]json.RawMessage{tc.key: value}) - if err != nil || string(got) != tc.after || !json.Valid(got) { - t.Fatalf("edit: %v\nwant %q\ngot %q", err, tc.after, got) - } - }) - } -} - -func TestJSONFieldEditsRejectInvalidOrAmbiguousObjects(t *testing.T) { - for _, source := range []string{`null`, `[]`, `{"name":`, `{"name":"a","name":"b"}`, `{} {}`} { - if _, err := updateJSONFields([]byte(source), map[string]json.RawMessage{"name": json.RawMessage(`"web"`)}); err == nil { - t.Errorf("accepted %q", source) - } - } -} diff --git a/packages/cli/internal/modules/creation/languages_test.go b/packages/cli/internal/modules/creation/languages_test.go deleted file mode 100644 index dda25609..00000000 --- a/packages/cli/internal/modules/creation/languages_test.go +++ /dev/null @@ -1,197 +0,0 @@ -package creation - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/template" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/fsutil" - "golang.org/x/mod/modfile" -) - -func addLanguageProject(t *testing.T, s *Service, root, id, name string) error { - t.Helper() - r, err := template.Fetch(context.Background(), "") - if err != nil { - t.Fatal(err) - } - for i := range r.Templates { - if r.Templates[i].ID == id { - _, err := s.AddProject(context.Background(), root, ProjectInput{Template: &r.Templates[i], Name: name}) - return err - } - } - t.Fatalf("missing template %s", id) - return nil -} - -func TestFirstProjectInitializesLanguageWorkspaceInEitherOrder(t *testing.T) { - for _, first := range []string{"go", "node"} { - t.Run(first, func(t *testing.T) { - s := newCreationService(t) - root := filepath.Join(t.TempDir(), "workspace with spaces") - if _, err := s.CreateWorkspace(context.Background(), WorkspaceInput{TargetDir: root, Name: "demo"}); err != nil { - t.Fatal(err) - } - id, name, secondID, secondName := "go-api", "api", "react-spa", "web" - if first == "node" { - id, name, secondID, secondName = secondID, secondName, id, name - } - if err := addLanguageProject(t, s, root, id, name); err != nil { - t.Fatal(err) - } - absent := "package.json" - if first == "node" { - absent = "go.work" - } - if _, err := os.Stat(filepath.Join(root, absent)); !os.IsNotExist(err) { - t.Fatalf("first %s project created %s", first, absent) - } - if first == "go" { - b, _ := os.ReadFile(filepath.Join(root, "go.work")) - if !strings.Contains(string(b), "./services/api") { - t.Fatalf("first module not registered: %s", b) - } - for _, path := range []string{".husky", ".changeset", "pnpm-workspace.yaml", "go.work.sum"} { - if _, err := os.Stat(filepath.Join(root, path)); !os.IsNotExist(err) { - t.Fatalf("unexpected %s", path) - } - } - } - if err := addLanguageProject(t, s, root, secondID, secondName); err != nil { - t.Fatal(err) - } - if err := addLanguageProject(t, s, root, "go-lib", "shared"); err != nil { - t.Fatal(err) - } - pkg, _ := os.ReadFile(filepath.Join(root, "apps/web/package.json")) - if !strings.Contains(string(pkg), `"name": "web"`) { - t.Fatalf("project kept template package name: %s", pkg) - } - b, _ := os.ReadFile(filepath.Join(root, "go.work")) - f, err := modfile.ParseWork("go.work", b, nil) - if err != nil || len(f.Use) != 2 { - t.Fatalf("members: %s %v", b, err) - } - m, err := workspace.ReadManifest(root) - if err != nil { - t.Fatal(err) - } - if len(m.Projects) != 3 { - t.Fatalf("projects = %v", m.Projects) - } - plan := fsutil.NewFilePlan(root) - if err := planLanguages(plan, m); err != nil { - t.Fatal(err) - } - for path, after := range plan.Overlay() { - before, _ := os.ReadFile(filepath.Join(root, path)) - if string(before) != string(after) { - t.Errorf("not idempotent: %s", path) - } - } - rootMise, _ := os.ReadFile(filepath.Join(root, workspace.MiseConfigFilename)) - if !strings.Contains(string(rootMise), "go = '1.27.0'") || !strings.Contains(string(rootMise), "pnpm =") { - t.Fatalf("wrong root tools: %s", rootMise) - } - hk, err := os.ReadFile(filepath.Join(root, workspace.HooksConfigFilename)) - if err != nil { - t.Fatal(err) - } - for _, step := range []string{`["api:format"]`, `["shared:format"]`, `["web:lint"]`, `["web:format"]`} { - if !strings.Contains(string(hk), step) { - t.Errorf("missing hk step %s", step) - } - } - rootPackage, err := os.ReadFile(filepath.Join(root, "package.json")) - if err != nil { - t.Fatal(err) - } - for _, obsolete := range []string{"husky", "commitlint", "changeset"} { - if strings.Contains(string(rootPackage), obsolete) { - t.Errorf("obsolete workspace tooling %s", obsolete) - } - } - for _, obsolete := range []string{".husky", "commitlint.config.js", ".changeset"} { - if _, err := os.Stat(filepath.Join(root, obsolete)); !os.IsNotExist(err) { - t.Errorf("obsolete workspace file %s", obsolete) - } - } - for _, project := range m.Projects { - if _, err := os.Stat(filepath.Join(root, project.RelativeDir, workspace.MiseConfigFilename)); !os.IsNotExist(err) { - t.Fatalf("unexpected project mise configuration for %s: %v", project.Name, err) - } - } - }) - } -} - -func TestAddConflictLeavesManifestAndRootConfigurationsUnchanged(t *testing.T) { - for _, conflict := range []struct{ file, content, template string }{ - {"go.work", "invalid Go syntax", "go-api"}, - {"pnpm-workspace.yaml", "packages: [ '!apps/*' ]\n", "react-spa"}, - {workspace.MiseConfigFilename, "# one:managed-v1 invalid", "go-api"}, - {workspace.HooksConfigFilename, "// one:managed-v1 invalid", "go-api"}, - } { - t.Run(conflict.file, func(t *testing.T) { - s := newCreationService(t) - root := filepath.Join(t.TempDir(), "demo") - if _, err := s.CreateWorkspace(context.Background(), WorkspaceInput{TargetDir: root, Name: "demo"}); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, conflict.file), []byte(conflict.content), 0o644); err != nil { - t.Fatal(err) - } - manifest, _ := os.ReadFile(filepath.Join(root, workspace.ManifestFilename)) - if err := addLanguageProject(t, s, root, conflict.template, "new-project"); err == nil { - t.Fatal("expected conflict") - } - after, _ := os.ReadFile(filepath.Join(root, workspace.ManifestFilename)) - if string(after) != string(manifest) { - t.Fatal("failed add changed manifest") - } - b, _ := os.ReadFile(filepath.Join(root, conflict.file)) - if string(b) != conflict.content { - t.Fatal("failed add overwrote user file") - } - for _, path := range []string{"services/new-project", "apps/new-project", "package.json"} { - if _, err := os.Stat(filepath.Join(root, path)); !os.IsNotExist(err) { - t.Fatalf("failed add left %s", path) - } - } - }) - } -} - -func TestUnsupportedNodeManagerIsRejectedWithoutMutation(t *testing.T) { - s := newCreationService(t) - root := filepath.Join(t.TempDir(), "demo") - if _, err := s.CreateWorkspace(context.Background(), WorkspaceInput{TargetDir: root, Name: "demo"}); err != nil { - t.Fatal(err) - } - user := `{"name":"custom","packageManager":"npm@11.0.0","scripts":{"custom":"echo keep"},"workspaces":["custom/*"]}` - if err := os.WriteFile(filepath.Join(root, "package.json"), []byte(user), 0o644); err != nil { - t.Fatal(err) - } - if err := addLanguageProject(t, s, root, "react-spa", "web"); err == nil { - t.Fatal("unsupported package manager accepted") - } - pkg, _ := os.ReadFile(filepath.Join(root, "package.json")) - if string(pkg) != user { - t.Fatalf("user package.json changed: %s", pkg) - } - if _, err := os.Stat(filepath.Join(root, "pnpm-workspace.yaml")); !os.IsNotExist(err) { - t.Fatal("npm workspace converted to pnpm") - } - m, _ := workspace.ReadManifest(root) - if len(m.Projects) != 0 { - t.Fatal("failed add published a project") - } - if _, err := os.Stat(filepath.Join(root, "apps/web")); !os.IsNotExist(err) { - t.Fatal("failed add left a generated project") - } -} diff --git a/packages/cli/internal/modules/creation/package_format_test.go b/packages/cli/internal/modules/creation/package_format_test.go deleted file mode 100644 index 1baf5040..00000000 --- a/packages/cli/internal/modules/creation/package_format_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package creation - -import ( - "context" - "encoding/json" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - - internaltoolchain "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/toolchain" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/template" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/fsutil" -) - -func TestConfigureNodePackagePreservesTemplateFormatting(t *testing.T) { - const source = "{\n\t\"name\": \"template\",\n\t\"scripts\": {\n\t\t\"lint\": \"echo '' && echo done\",\n\t\t\"check\": \"pnpm run lint && pnpm run format\"\n\t},\n\t\"files\": [\"src\", \"dist\"],\n\t\"custom\": { \"command\": \"pnpm run untouched\" },\n\t\"engines\": { \"node\": \">=24\" },\n\t\"packageManager\": \"pnpm@12.3.4\"\n}\n" - for _, manager := range []string{"pnpm"} { - t.Run(manager, func(t *testing.T) { - p := fsutil.NewFilePlan(t.TempDir()) - root := `{"packageManager":"` + manager + `@1.2.3"}` - if err := p.Set("package.json", []byte(root), 0o644); err != nil { - t.Fatal(err) - } - if err := p.Set("apps/web/package.json", []byte(source), 0o644); err != nil { - t.Fatal(err) - } - if err := configureNodePackage(p, "apps/web", "web", manager); err != nil { - t.Fatal(err) - } - want := strings.Replace(source, `"name": "template"`, `"name": "web"`, 1) - want = strings.Replace(want, "pnpm@12.3.4", manager+"@1.2.3", 1) - want = strings.Replace(want, "pnpm run lint && pnpm run format", manager+" run lint && "+manager+" run format", 1) - got, err := p.Read("apps/web/package.json") - if err != nil || string(got) != want { - t.Fatalf("generated package changed unrelated formatting: %v\nwant:\n%s\ngot:\n%s", err, want, got) - } - if err := configureNodePackage(p, "apps/web", "web", manager); err != nil { - t.Fatal(err) - } - again, _ := p.Read("apps/web/package.json") - if string(again) != string(got) { - t.Fatal("configuration is not idempotent") - } - }) - } -} - -// Use the real formatter against projects created through the public service, -// including package rewrites and generated files, not just template sources. -func TestGeneratedNodeProjectsPassFormatting(t *testing.T) { - formatter := os.Getenv("ONE_TEST_OXFMT_BINARY") - if formatter == "" { - t.Skip("set ONE_TEST_OXFMT_BINARY to run generated-project formatting checks") - } - internaltoolchain.RegisterBundled() - registry, err := template.Fetch(context.Background(), "") - if err != nil { - t.Fatal(err) - } - for _, entry := range registry.Templates { - if entry.Toolchain != "node" { - continue - } - t.Run(entry.ID, func(t *testing.T) { - s := newCreationService(t) - root := filepath.Join(t.TempDir(), "format-check") - if _, err := s.CreateWorkspace(context.Background(), WorkspaceInput{TargetDir: root, Name: "format-check"}); err != nil { - t.Fatal(err) - } - result, err := s.AddProject(context.Background(), root, ProjectInput{Template: &entry, Name: "sample"}) - if err != nil { - t.Fatal(err) - } - projects := result.Project.Projects - if len(projects) == 0 { - projects = []ProjectResult{result.Project} - } - for _, project := range projects { - cmd := exec.Command(formatter, "--check", ".") - cmd.Dir = project.TargetPath - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("generated %s formatting: %v\n%s", project.Name, err, out) - } - b, err := os.ReadFile(filepath.Join(project.TargetPath, "package.json")) - var pkg struct{ Name string } - if err != nil || json.Unmarshal(b, &pkg) != nil || pkg.Name != project.Name { - t.Fatalf("generated package name: %s (%v)", b, err) - } - } - cmd := exec.Command(formatter, "--check", "package.json") - cmd.Dir = root - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("workspace package formatting: %v\n%s", err, out) - } - - }) - } -} diff --git a/packages/cli/internal/modules/creation/runnable_templates_test.go b/packages/cli/internal/modules/creation/runnable_templates_test.go deleted file mode 100644 index ac40f589..00000000 --- a/packages/cli/internal/modules/creation/runnable_templates_test.go +++ /dev/null @@ -1,187 +0,0 @@ -package creation - -import ( - "context" - "encoding/json" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "testing" - "time" - - internaltoolchain "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/toolchain" -) - -// This dependency-backed gate is explicit so ordinary Go unit tests remain -// offline. mise run check:templates and the Linux CI job enable it. -func TestRunnableTemplateSourcesAndProjects(t *testing.T) { - if os.Getenv("ONE_TEST_TEMPLATE_BUILDS") != "1" { - t.Skip("run mise run check:templates") - } - _, here, _, _ := runtime.Caller(0) - repo := filepath.Clean(filepath.Join(filepath.Dir(here), "../../../../..")) - sources := filepath.Join(repo, "packages/templates") - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) - defer cancel() - run := func(dir string, args ...string) { - t.Helper() - cmd := exec.CommandContext(ctx, args[0], args[1:]...) - cmd.Dir = dir - cmd.Env = append(os.Environ(), "CI=true") - t.Logf("%s: %s", dir, strings.Join(args, " ")) - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("%v\n%s", err, out) - } - } - // Local go.work files make ordinary commands in the source directories work. - for _, id := range []string{"go-lib", "go-api"} { - run(filepath.Join(sources, id), "go", "test", "-mod=readonly", "./...") - run(filepath.Join(sources, id), "go", "build", "-mod=readonly", "./...") - } - desktopSource := filepath.Join(sources, "electron-app") - run(desktopSource, "pnpm", "install", "--no-frozen-lockfile") - run(desktopSource, "pnpm", "run", "format:check") - run(desktopSource, "pnpm", "run", "build") - run(desktopSource, "pnpm", "run", "test") - formatter := filepath.Join(desktopSource, "node_modules/.bin/oxfmt") - if runtime.GOOS == "windows" { - formatter += ".cmd" - } - t.Setenv("ONE_TEST_OXFMT_BINARY", formatter) - internaltoolchain.RegisterBundled() - TestGeneratedNodeProjectsPassFormatting(t) - - // Exercise a user-owned workspace with the repository's installed pnpm. - // The generator must honor this configuration rather than the starter pin. - s := newCreationService(t) - root := filepath.Join(t.TempDir(), "workspace with spaces") - if _, err := s.CreateWorkspace(ctx, WorkspaceInput{TargetDir: root, Name: "runnable"}); err != nil { - t.Fatal(err) - } - sourcePackage, err := os.ReadFile(filepath.Join(desktopSource, "package.json")) - if err != nil { - t.Fatal(err) - } - manager := packageManagerFromTestJSON(t, sourcePackage) - raw := []byte("{\"private\":true,\"packageManager\":" + manager + "}\n") - if err := os.WriteFile(filepath.Join(root, "package.json"), raw, 0o644); err != nil { - t.Fatal(err) - } - policy := "packages: []\n" + pnpmWorkspaceContent[strings.Index(pnpmWorkspaceContent, "# Native"):] - if err := os.WriteFile(filepath.Join(root, "pnpm-workspace.yaml"), []byte(policy), 0o644); err != nil { - t.Fatal(err) - } - for _, project := range []struct{ id, name string }{{"go-api", "api"}, {"go-lib", "shared"}, {"electron-app", "Alpha_Desktop"}, {"electron-app", "zulu-desktop"}, {"nextjs-site", "website"}, {"fumadocs-docs", "docs"}, {"nestjs-api", "nest-api"}, {"react-spa", "web"}, {"nextjs-app", "app"}, {"expo-mobile", "mobile"}, {"ts-library", "utils"}, {"empty-app", "custom-app"}, {"empty-service", "custom-service"}, {"empty-library", "custom-library"}} { - if err := addLanguageProject(t, s, root, project.id, project.name); err != nil { - t.Fatal(err) - } - } - pages := map[string][]struct{ path, lang, text string }{ - "website": { - {"en/index.html", "en-US", "Build something worth sharing"}, - {"zh/index.html", "zh-CN", "让值得分享的想法成为网站"}, - {"en/about/index.html", "en-US", "A simple place to tell your story"}, - {"zh/about/index.html", "zh-CN", "在这里讲述你的故事"}, - }, - "docs": { - {"en/index.html", "en-US", "My Docs"}, - {"zh/index.html", "zh-CN", "我的文档"}, - {"en/docs/guide/index.html", "en-US", "Writing guide"}, - {"zh/docs/guide/index.html", "zh-CN", "编写指南"}, - }, - } - for _, name := range []string{"website", "docs"} { - for _, excluded := range []string{"out", ".next", ".source", "node_modules", "pnpm-lock.yaml", "pnpm-workspace.yaml"} { - if _, err := os.Stat(filepath.Join(root, "apps", name, excluded)); !os.IsNotExist(err) { - t.Fatalf("template leaked %s into %s: %v", excluded, name, err) - } - } - } - run(root, "pnpm", "install", "--no-frozen-lockfile") - for _, dir := range []string{"services/api", "packages/shared"} { - run(filepath.Join(root, dir), "go", "test", "-mod=readonly", "./...") - } - // Check the complete application stack in one workspace, including the - // Expo/Next.js React type versions that previously conflicted. - for _, dir := range []string{"services/nest-api", "apps/web", "apps/app", "apps/mobile", "packages/utils"} { - run(filepath.Join(root, dir), "pnpm", "run", "check") - } - for _, dir := range []string{"services/nest-api", "apps/web", "apps/app", "packages/utils"} { - run(filepath.Join(root, dir), "pnpm", "run", "build") - } - for _, dir := range []string{"services/nest-api", "apps/mobile", "packages/utils"} { - run(filepath.Join(root, dir), "pnpm", "run", "test") - } - run(filepath.Join(root, "services/nest-api"), "pnpm", "run", "test:e2e") - run(filepath.Join(root, "apps/mobile"), "pnpm", "exec", "expo", "install", "--check") - run(filepath.Join(root, "apps/mobile"), "pnpm", "exec", "expo", "export", "--platform", "web") - for _, project := range []struct{ name, pkg string }{{"Alpha_Desktop", "alpha-desktop"}, {"zulu-desktop", "zulu-desktop"}} { - run(root, "pnpm", "--filter", project.pkg+"-main...", "run", "build") - run(filepath.Join(root, "services", project.name+"-main"), "pnpm", "run", "test") - for _, member := range []struct{ dir, suffix string }{{"apps", "-renderer"}, {"services", "-main"}, {"packages", "-preload"}} { - run(filepath.Join(root, member.dir, project.name+member.suffix), formatter, "--check", ".") - } - } - for _, name := range []string{"website", "docs"} { - dir := filepath.Join(root, "apps", name) - run(dir, "pnpm", "run", "build") - run(dir, "pnpm", "run", "typecheck") - run(dir, "pnpm", "run", "lint") - for _, page := range pages[name] { - raw, err := os.ReadFile(filepath.Join(dir, "out", page.path)) - if err != nil || !strings.Contains(string(raw), `lang="`+page.lang+`"`) || !strings.Contains(string(raw), page.text) { - t.Fatalf("%s/%s missing localized document: %v", name, page.path, err) - } - } - } - docs := filepath.Join(root, "apps", "docs") - for _, page := range []struct{ locale, search, toc string }{{"en", "Search", "On this page"}, {"zh", "搜索", "本页目录"}} { - raw, err := os.ReadFile(filepath.Join(docs, "out", page.locale, "docs", "index.html")) - if err != nil || !strings.Contains(string(raw), page.search) || !strings.Contains(string(raw), page.toc) || !strings.Contains(string(raw), "/"+page.locale+"/docs/guide") || strings.Contains(string(raw), "./guide.mdx") { - t.Fatalf("%s documentation UI or relative links invalid: %v", page.locale, err) - } - } - // Use the real browser search client against the exported index over HTTP. - // This catches missing exports, locale filters, and broken result URLs. - run(docs, "node", "--input-type=module", "--eval", ` -import assert from 'node:assert/strict'; -import { readFileSync, existsSync } from 'node:fs'; -import { createServer } from 'node:http'; -import { staticClient } from 'fumadocs-core/search/client/orama-static'; -const index = readFileSync('out/api/search'); -const server = createServer((req, res) => { - res.setHeader('Content-Type', 'application/json'); - res.end(index); -}); -await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); -try { - const from = 'http://127.0.0.1:' + server.address().port + '/api/search'; - for (const [locale, query] of [['en', 'guide'], ['zh', '指南']]) { - const results = await staticClient({ from, locale }).search(query); - assert(results.length > 0, locale + ' search returned no results'); - for (const result of results) { - assert(result.url.startsWith('/' + locale + '/docs'), result.url); - const path = result.url.split('#')[0].replace(/\/$/, ''); - assert(existsSync('out' + path + '/index.html'), result.url); - } - assert.deepEqual(await staticClient({ from, locale }).search('zzzznonexistentzzzz'), []); - } -} finally { - await new Promise(resolve => server.close(resolve)); -} -`) -} - -func packageManagerFromTestJSON(t *testing.T, raw []byte) string { - t.Helper() - var pkg map[string]json.RawMessage - if err := json.Unmarshal(raw, &pkg); err != nil { - t.Fatal(err) - } - if len(pkg["packageManager"]) == 0 { - t.Fatal("missing source packageManager") - } - return string(pkg["packageManager"]) -} diff --git a/packages/cli/internal/modules/creation/service_test.go b/packages/cli/internal/modules/creation/service_test.go deleted file mode 100644 index a0bf26d5..00000000 --- a/packages/cli/internal/modules/creation/service_test.go +++ /dev/null @@ -1,143 +0,0 @@ -package creation - -import ( - "context" - "errors" - "os" - "path/filepath" - "strings" - "testing" - - catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/template" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" - environmentmodule "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/environment" -) - -func TestCreateWorkspaceObservesOnlyAfterSuccessfulCreation(t *testing.T) { - target := filepath.Join(t.TempDir(), "observed") - wantWarning := errors.New("registry unavailable") - var observedRoot, observedSource string - service := newCreationService(t, func(_ context.Context, root, source string) error { - observedRoot, observedSource = root, source - if !workspace.HasManifest(root) { - t.Fatal("observer ran before the manifest was published") - } - return wantWarning - }) - - result, err := service.CreateWorkspace(context.Background(), WorkspaceInput{ - TargetDir: target, Name: "observed", EnvBackend: workspace.EnvBackendInfisical, - }) - if err != nil { - t.Fatalf("CreateWorkspace() error = %v", err) - } - if !errors.Is(result.RegistryWarn, wantWarning) { - t.Fatalf("RegistryWarn = %v, want %v", result.RegistryWarn, wantWarning) - } - if observedRoot != target || observedSource != "create" { - t.Fatalf("Observe(root, source) = (%q, %q)", observedRoot, observedSource) - } -} - -func TestServiceOwnsWorkspaceAndProjectCreation(t *testing.T) { - service := newCreationService(t) - target := filepath.Join(t.TempDir(), "demo") - - created, err := service.CreateWorkspace(context.Background(), WorkspaceInput{ - TargetDir: target, - Name: "demo", - EnvBackend: workspace.EnvBackendInfisical, - }) - if err != nil { - t.Fatal(err) - } - if created.TargetDir != target || created.PackageManager != "" || created.EnvBackend != "infisical" { - t.Fatalf("CreateWorkspace() = %+v", created) - } - manifest, err := workspace.ReadManifest(target) - if err != nil { - t.Fatal(err) - } - if got := workspace.EnvBackend(manifest); got != "" { - t.Fatalf("environment backend = %q", got) - } - if _, err := os.Stat(filepath.Join(target, ".gitignore")); err != nil { - t.Fatalf("workspace files were not generated: %v", err) - } - - registry, err := template.Fetch(context.Background(), "") - if err != nil { - t.Fatal(err) - } - var selected *template.Template - for index := range registry.Templates { - if registry.Templates[index].ID == "react-spa" { - selected = ®istry.Templates[index] - break - } - } - if selected == nil { - t.Fatal("react-spa template is absent") - } - added, err := service.AddProject(context.Background(), target, ProjectInput{ - Template: selected, Name: "web", - }) - if err != nil { - t.Fatal(err) - } - if added.Project.Name != "web" || added.Project.TemplateID != "react-spa" { - t.Fatalf("AddProject() = %+v", added.Project) - } - manifest, err = workspace.ReadManifest(target) - if err != nil { - t.Fatal(err) - } - if len(manifest.Projects) != 1 || manifest.Projects[0].Name != "web" { - t.Fatalf("manifest projects = %+v", manifest.Projects) - } - raw, err := os.ReadFile(workspace.ManifestPath(target)) - if err != nil || strings.Contains(string(raw), "template") { - t.Fatalf("creation persisted template identity: %s %v", raw, err) - } -} - -func TestCreateWorkspaceRechecksTargetBeforeMutation(t *testing.T) { - observed := false - service := newCreationService(t, func(context.Context, string, string) error { - observed = true - return nil - }) - target := t.TempDir() - marker := filepath.Join(target, "keep.txt") - if err := os.WriteFile(marker, []byte("user data"), 0o644); err != nil { - t.Fatal(err) - } - - _, err := service.CreateWorkspace(context.Background(), WorkspaceInput{ - TargetDir: target, Name: "demo", EnvBackend: workspace.EnvBackendInfisical, - }) - if coded, ok := err.(interface{ ErrorCode() string }); !ok || coded.ErrorCode() != "EXISTING_TARGET_NOT_EMPTY" { - t.Fatalf("error = %v", err) - } - if raw, readErr := os.ReadFile(marker); readErr != nil || string(raw) != "user data" { - t.Fatalf("existing target was mutated: raw=%q err=%v", raw, readErr) - } - if observed { - t.Fatal("failed creation must not be registered") - } -} - -func newCreationService(t *testing.T, observers ...WorkspaceObserver) *Service { - t.Helper() - backendCatalog := catalog.Builtin() - environments, err := environmentmodule.NewService(backendCatalog) - if err != nil { - t.Fatal(err) - } - service, err := NewService(environments, observers...) - if err != nil { - t.Fatal(err) - } - return service -} diff --git a/packages/cli/internal/modules/creation/skills_test.go b/packages/cli/internal/modules/creation/skills_test.go deleted file mode 100644 index af73d00d..00000000 --- a/packages/cli/internal/modules/creation/skills_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package creation - -import ( - "context" - "os" - "path/filepath" - "reflect" - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/template" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" - skillsmodule "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/skills" -) - -type installFunc func(context.Context, string, []skillsmodule.Selection) []string - -func (f installFunc) Install(ctx context.Context, root string, selections []skillsmodule.Selection) []string { - return f(ctx, root, selections) -} - -func TestSkillsRunAfterCreationAndCompositeAndCanBeSkipped(t *testing.T) { - for _, skip := range []bool{false, true} { - service := newCreationService(t) - root := filepath.Join(t.TempDir(), "demo") - calls := 0 - service.Skills = installFunc(func(_ context.Context, directory string, plan []skillsmodule.Selection) []string { - calls++ - if directory != root { - t.Fatalf("skills target=%s want workspace %s", directory, root) - } - if calls == 1 { - var names []string - for _, selection := range plan { - names = append(names, selection.Names...) - } - if !reflect.DeepEqual(names, []string{"one-cli", "find-skills"}) { - t.Fatalf("creation must install usage and discovery skills: %v", plan) - } - } - manifest, err := workspace.ReadManifest(root) - if err != nil { - t.Fatal("skills ran before creation completed", err) - } - if calls == 2 { - if len(manifest.Projects) != 3 || len(manifest.Groups) != 1 { - t.Fatal("skills ran before the complete Electron group was published") - } - for _, project := range manifest.Projects { - if _, err := os.Stat(filepath.Join(root, project.RelativeDir, "package.json")); err != nil { - t.Fatal("skills ran before member files existed", err) - } - } - } - return []string{"installation unavailable; retry manually"} - }) - created, err := service.CreateWorkspace(context.Background(), WorkspaceInput{TargetDir: root, Name: "demo", SkipSkills: skip}) - if err != nil { - t.Fatal(err) - } - registry, err := template.Fetch(context.Background(), "") - if err != nil { - t.Fatal(err) - } - entry, err := registry.Resolve("electron-app") - if err != nil { - t.Fatal(err) - } - added, err := service.AddProject(context.Background(), root, ProjectInput{Template: entry, Name: "desktop", SkipSkills: skip}) - if err != nil || len(added.Project.Projects) != 3 { - t.Fatalf("valid project failed because of skills: %+v %v", added, err) - } - if skip { - if calls != 0 || len(created.SkillsWarnings) != 0 { - t.Fatalf("skip called installer: %d %+v", calls, created) - } - } else if calls != 2 || len(created.SkillsWarnings) != 1 || len(added.Project.Warnings) == 0 { - t.Fatalf("auxiliary failure lost: calls=%d create=%+v add=%+v", calls, created, added) - } - } -} diff --git a/packages/cli/internal/modules/creation/workspace_files_test.go b/packages/cli/internal/modules/creation/workspace_files_test.go deleted file mode 100644 index 832b05fc..00000000 --- a/packages/cli/internal/modules/creation/workspace_files_test.go +++ /dev/null @@ -1,108 +0,0 @@ -package creation - -// Unit tests for workspace generation. Assert every file lands at its -// expected path with the expected JSON shape; cross-binary parity is -// captured separately by the e2e snapshot suite. - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/pelletier/go-toml/v2" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" -) - -func TestGenerateWorkspaceFiles(t *testing.T) { - tmp := t.TempDir() - target := filepath.Join(tmp, "demo") - - err := generateWorkspaceFiles(target, workspaceFilesOptions{ - ProjectName: "demo", - }) - if err != nil { - t.Fatalf("generateWorkspaceFiles() = %v", err) - } - - for _, rel := range []string{"one.manifest.toml", ".gitignore", "AGENTS.md", "apps", "services", "packages"} { - if _, err := os.Stat(filepath.Join(target, rel)); err != nil { - t.Fatalf("missing %s: %v", rel, err) - } - } - for _, rel := range []string{"package.json", "pnpm-workspace.yaml", "go.work", ".husky", ".changeset", "commitlint.config.js"} { - if _, err := os.Stat(filepath.Join(target, rel)); !os.IsNotExist(err) { - t.Fatalf("empty workspace should not create %s", rel) - } - } - - // Manifest must be parseable + carry the current minimum: version, - // workspace identity, and an empty projects array. The current schema dropped - // top-level packageManager and ai blocks (per-project still has - // packageManager). - manifestRaw, err := os.ReadFile(filepath.Join(target, "one.manifest.toml")) - if err != nil { - t.Fatal(err) - } - var m map[string]any - if err := toml.Unmarshal(manifestRaw, &m); err != nil { - t.Fatalf("manifest parse: %v", err) - } - if v, _ := m["version"].(int64); v != int64(workspace.ManifestVersion) { - t.Errorf("manifest version=%v; want %d", m["version"], workspace.ManifestVersion) - } - if _, has := m["packageManager"]; has { - t.Errorf("manifest must not carry top-level packageManager; got %v", m["packageManager"]) - } - if _, has := m["ai"]; has { - t.Errorf("manifest must not carry top-level ai block; got %v", m["ai"]) - } - - // workspace block must be set at scaffold time so env init can read - // the workspace identity without re-prompting the user. - ws, ok := m["workspace"].(map[string]any) - if !ok { - t.Fatalf("manifest has no workspace block; got keys=%v", keys(m)) - } - if ws["name"] != "demo" { - t.Errorf("workspace.name = %v; want demo", ws["name"]) - } - if _, has := ws["roots"]; has { - t.Errorf("workspace must not carry roots override; got %v", ws["roots"]) - } - id, _ := ws["id"].(string) - if !strings.HasPrefix(id, "demo-") || len(id) != len("demo-")+6 { - t.Errorf("workspace.id = %q; want shape demo-<6-hex>", id) - } -} - -func keys(m map[string]any) []string { - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) - } - return out -} - -func TestIsDirectoryEmpty(t *testing.T) { - tmp := t.TempDir() - empty, err := isDirectoryEmpty(tmp) - if err != nil || !empty { - t.Errorf("expected fresh tempdir empty; got empty=%v err=%v", empty, err) - } - if err := os.WriteFile(filepath.Join(tmp, "x"), []byte("y"), 0o644); err != nil { - t.Fatal(err) - } - empty, err = isDirectoryEmpty(tmp) - if err != nil || empty { - t.Errorf("expected non-empty after write; got empty=%v err=%v", empty, err) - } - - missing, err := isDirectoryEmpty(filepath.Join(tmp, "does-not-exist")) - if err != nil { - t.Errorf("missing dir should not error: %v", err) - } - if !missing { - t.Errorf("missing dir should be empty=true") - } -} diff --git a/packages/cli/internal/modules/dependencies/pnpm_test.go b/packages/cli/internal/modules/dependencies/pnpm_test.go deleted file mode 100644 index c7c31e32..00000000 --- a/packages/cli/internal/modules/dependencies/pnpm_test.go +++ /dev/null @@ -1,209 +0,0 @@ -package dependencies - -import ( - "bytes" - "context" - "errors" - "io" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" - runtimeport "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/runtime" -) - -func TestPNPMDevelopmentReusesManualInstallAndUpdatesNewProjects(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("native pnpm executable test") - } - pnpm, err := exec.LookPath("pnpm") - if err != nil { - t.Skip("pnpm is not installed") - } - if _, err := exec.LookPath("node"); err != nil { - t.Skip("node is not installed") - } - root, home := t.TempDir(), t.TempDir() - t.Setenv("HOME", home) - t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config")) - t.Setenv("XDG_CACHE_HOME", filepath.Join(home, "cache")) - t.Setenv("CI", "true") - versionCmd := exec.Command(pnpm, "--version") - versionCmd.Dir = root - version, err := versionCmd.Output() - if err != nil { - t.Fatalf("pnpm --version: %v", err) - } - if !supportsPNPMDependencyCheck(string(version)) { - t.Skipf("pnpm %s lacks dependency verification", version) - } - write(t, root, "package.json", `{"name":"workspace","private":true}`) - write(t, root, "pnpm-workspace.yaml", "packages:\n - apps/*\n - packages/*\n - apps/web/apps/ui\n") - write(t, root, "packages/shared/package.json", `{"name":"shared","version":"1.0.0"}`) - write(t, root, "apps/web/package.json", `{"name":"web","workspaces":["apps/ui"]}`) - write(t, root, "apps/web/apps/ui/package.json", `{"name":"@web/ui","dependencies":{"shared":"workspace:*"}}`) - manual := exec.Command(pnpm, "install", "--no-frozen-lockfile", "--offline") - manual.Dir = root - if data, err := manual.CombinedOutput(); err != nil { - t.Fatalf("manual install: %v\n%s", err, data) - } - in := Input{Root: root, Manifest: &workspace.Manifest{Projects: []workspace.ManifestProject{ - project("web", "apps/web", "node"), project("shared", "packages/shared", "node"), - }}, Runtime: runtimeport.Builtin, Development: true} - installs := 0 - s := Service{Run: func(ctx context.Context, c runtimeport.Command, out, errOut io.Writer) error { - if c.Argv[0] == "pnpm" { - c.Argv[0] = pnpm - if c.Argv[1] == "install" { - installs++ - c.Argv = append(c.Argv, "--offline") // The fixture uses only workspace dependencies. - } - } - return runCommand(ctx, c, out, errOut) - }} - if err := s.Prepare(context.Background(), in); err != nil { - t.Fatal(err) - } - if installs != 0 { - t.Fatal("reinstalled after a successful manual pnpm install") - } - write(t, root, "apps/ccc/package.json", `{"name":"ccc","dependencies":{"shared":"workspace:*"}}`) - in.Manifest.Projects = append(in.Manifest.Projects, project("ccc", "apps/ccc", "node")) - before, _ := os.ReadFile(filepath.Join(root, "pnpm-lock.yaml")) - strict := in - strict.Development = false - if err := s.Prepare(context.Background(), strict); err == nil { - t.Fatal("strict preparation accepted an outdated lockfile") - } - after, _ := os.ReadFile(filepath.Join(root, "pnpm-lock.yaml")) - if !bytes.Equal(before, after) { - t.Fatal("strict preparation rewrote the lockfile") - } - installs = 0 - if err := s.Prepare(context.Background(), in); err != nil { - t.Fatalf("new project: %v", err) - } - after, _ = os.ReadFile(filepath.Join(root, "pnpm-lock.yaml")) - if !strings.Contains(string(after), "apps/ccc:") { - t.Fatalf("new project missing from lockfile: %s", after) - } - if !nodeInstalled(in) { - t.Fatal("new project's dependency was not linked") - } - if err := s.Prepare(context.Background(), in); err != nil { - t.Fatal(err) - } - if installs != 1 { - t.Fatalf("expected one install after adding a project, got %d", installs) - } - // The native check must notice a manifest edit even if links still exist. - write(t, root, "apps/ccc/package.json", `{"name":"ccc","dependencies":{"shared":"workspace:^"}}`) - if err := s.Prepare(context.Background(), in); err != nil { - t.Fatal(err) - } - if installs != 2 { - t.Fatal("a changed dependency specifier was ignored") - } - // No manifest entry is added for the UI package. Both native validation - // and the strict-build fingerprint must still notice edits inside it. - if err := s.Prepare(context.Background(), strict); err != nil { - t.Fatal(err) - } - if err := s.Prepare(context.Background(), strict); err != nil { - t.Fatal(err) - } - before, _ = os.ReadFile(filepath.Join(root, "pnpm-lock.yaml")) - write(t, root, "apps/web/apps/ui/package.json", `{"name":"@web/ui","dependencies":{"shared":"workspace:^"}}`) - if err := s.Prepare(context.Background(), strict); err == nil { - t.Fatal("cached build ignored an internal manifest edit") - } - after, _ = os.ReadFile(filepath.Join(root, "pnpm-lock.yaml")) - if !bytes.Equal(before, after) { - t.Fatal("strict build changed the lockfile") - } - installs = 0 - if err := s.Prepare(context.Background(), in); err != nil { - t.Fatal(err) - } - if installs != 1 { - t.Fatal("development ignored an internal manifest edit") - } - if err := s.Prepare(context.Background(), in); err != nil { - t.Fatal(err) - } - if installs != 1 { - t.Fatal("reinstalled an unchanged composite project") - } -} - -func TestNodeInstallFailureIsOnlyPrintedOnce(t *testing.T) { - t.Cleanup(func() { _ = i18n.Init(i18n.DefaultLocale) }) - for _, locale := range []string{"en-US", "zh-CN"} { - _ = i18n.Init(locale) - root := t.TempDir() - t.Setenv("XDG_CACHE_HOME", t.TempDir()) - write(t, root, "package.json", `{"packageManager":"pnpm@12.3.4"}`) - write(t, root, "apps/web/package.json", `{"dependencies":{"missing":"1.0.0"}}`) - diagnostic := "ERR_PNPM_FETCH_500: registry unavailable\n" - calls := 0 - service := Service{Run: func(_ context.Context, c runtimeport.Command, out, errOut io.Writer) error { - if c.Argv[1] == "--version" { - _, _ = io.WriteString(out, "12.3.4\n") - return nil - } - calls++ - if strings.Join(c.Argv, " ") != "pnpm install --no-frozen-lockfile" { - t.Fatalf("unexpected install: %v", c.Argv) - } - _, _ = io.WriteString(errOut, diagnostic) - return errors.New("exit status 1") - }} - var log bytes.Buffer - in := Input{Root: root, Manifest: &workspace.Manifest{Projects: []workspace.ManifestProject{project("web", "apps/web", "node")}}, Development: true, Log: &log} - err := service.Prepare(context.Background(), in) - failure, ok := err.(*output.Error) - if !ok { - t.Fatalf("expected structured error, got %v", err) - } - if strings.Count(log.String()+err.Error(), strings.TrimSpace(diagnostic)) != 1 { - t.Fatalf("duplicated diagnostic: %s\n%v", log.String(), err) - } - if failure.Context["stderr"] != diagnostic || calls != 1 { - t.Fatalf("lost diagnostic or retried failed install: %+v, calls=%d", failure.Context, calls) - } - in.Log = nil - err = service.Prepare(context.Background(), in) - if err == nil || !strings.Contains(err.Error(), strings.TrimSpace(diagnostic)) { - t.Fatalf("discarded diagnostic when logs were disabled: %v", err) - } - } -} - -func TestPNPMValidationCancellationDoesNotStartInstall(t *testing.T) { - root := t.TempDir() - write(t, root, "package.json", `{"packageManager":"pnpm@12.3.4"}`) - write(t, root, "apps/web/package.json", `{}`) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - s := Service{Run: func(_ context.Context, c runtimeport.Command, out, _ io.Writer) error { - if c.Argv[1] == "--version" { - _, _ = io.WriteString(out, "12.3.4\n") - return nil - } - if c.Argv[1] != "--config.verify-deps-before-run=error" { - t.Fatalf("installation started after cancellation: %v", c.Argv) - } - cancel() - return ctx.Err() - }} - err := s.Prepare(ctx, Input{Root: root, Manifest: &workspace.Manifest{Projects: []workspace.ManifestProject{project("web", "apps/web", "node")}}, Development: true}) - if !errors.Is(err, context.Canceled) { - t.Fatal(err) - } -} diff --git a/packages/cli/internal/modules/dependencies/service_test.go b/packages/cli/internal/modules/dependencies/service_test.go deleted file mode 100644 index d476842a..00000000 --- a/packages/cli/internal/modules/dependencies/service_test.go +++ /dev/null @@ -1,376 +0,0 @@ -package dependencies - -import ( - "archive/zip" - "bytes" - "context" - "errors" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "sync" - "testing" - "time" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" - runtimeport "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/runtime" -) - -func write(t *testing.T, root, path, content string) { - t.Helper() - file := filepath.Join(root, path) - if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(file, []byte(content), 0o644); err != nil { - t.Fatal(err) - } -} - -func project(name, dir, language string) workspace.ManifestProject { - return workspace.ManifestProject{Name: name, RelativeDir: dir, Toolchain: language} -} - -func setupGo(t *testing.T) string { - t.Helper() - if _, err := exec.LookPath("go"); err != nil { - t.Skip("Go not installed") - } - t.Setenv("GOTOOLCHAIN", "local") - t.Setenv("GOWORK", "") - t.Setenv("GOFLAGS", "-modcacherw") - t.Setenv("GOSUMDB", "off") - t.Setenv("GOPROXY", "off") - t.Setenv("GOMODCACHE", filepath.Join(t.TempDir(), "modules")) - return t.TempDir() -} - -func TestGoMissingSumsAndEmptyCachePreparedWithoutChangingModule(t *testing.T) { - root := setupGo(t) - proxy := filepath.Join(t.TempDir(), "proxy") - mod := "module example.com/dependency\ngo 1.23.0\n" - write(t, proxy, "example.com/dependency/@v/v1.0.0.mod", mod) - write(t, proxy, "example.com/dependency/@v/v1.0.0.info", `{"Version":"v1.0.0","Time":"2024-01-01T00:00:00Z"}`) - write(t, proxy, "example.com/dependency/@v/list", "v1.0.0\n") - var buf bytes.Buffer - z := zip.NewWriter(&buf) - for name, content := range map[string]string{"go.mod": mod, "dependency.go": "package dependency\nconst Value = 42\n"} { - w, err := z.Create("example.com/dependency@v1.0.0/" + name) - if err != nil { - t.Fatal(err) - } - if _, err := io.WriteString(w, content); err != nil { - t.Fatal(err) - } - } - if err := z.Close(); err != nil { - t.Fatal(err) - } - write(t, proxy, "example.com/dependency/@v/v1.0.0.zip", buf.String()) - urlPath := filepath.ToSlash(proxy) - if !strings.HasPrefix(urlPath, "/") { - urlPath = "/" + urlPath - } - t.Setenv("GOPROXY", "file://"+urlPath) - module := "module example.com/api\ngo 1.23.0\nrequire example.com/dependency v1.0.0\n" - write(t, root, "services/api/go.mod", module) - write(t, root, "services/api/main.go", "package main\nimport (\"fmt\"; \"example.com/dependency\")\nfunc main(){fmt.Println(dependency.Value)}\n") - in := Input{Root: root, Manifest: &workspace.Manifest{Projects: []workspace.ManifestProject{project("api", "services/api", "go")}}, Runtime: runtimeport.Builtin} - for i := 0; i < 3; i++ { - if i == 2 { - if err := os.RemoveAll(os.Getenv("GOMODCACHE")); err != nil { - t.Fatal(err) - } - } - if err := (Service{}).Prepare(context.Background(), in); err != nil { - t.Fatal(err) - } - b, _ := os.ReadFile(filepath.Join(root, "services/api/go.mod")) - if string(b) != module { - t.Fatalf("go.mod changed: %s", b) - } - sum, _ := os.ReadFile(filepath.Join(root, "services/api/go.sum")) - if !strings.Contains(string(sum), "example.com/dependency v1.0.0 h1:") { - t.Fatalf("missing content checksum: %s", sum) - } - } -} - -func TestGoWorkspaceUsesLocalLibraryWithNoRemoteVersion(t *testing.T) { - root := setupGo(t) - write(t, root, "go.work", "go 1.25.0\nuse (\n ./services/api\n \"./packages/shared lib\"\n)\n") - write(t, root, "services/api/go.mod", "module example.com/api\ngo 1.25.0\nrequire example.com/shared v0.0.0\n") - write(t, root, "services/api/main.go", "package main\nimport (\"fmt\"; \"example.com/shared\")\nfunc main(){fmt.Println(shared.Value)}\n") - write(t, root, "packages/shared lib/go.mod", "module example.com/shared\ngo 1.25.0\n") - write(t, root, "packages/shared lib/shared.go", "package shared\nconst Value = 42\n") - in := Input{Root: root, Manifest: &workspace.Manifest{Projects: []workspace.ManifestProject{project("api", "services/api", "go")}}} - if err := (Service{}).Prepare(context.Background(), in); err != nil { - t.Fatal(err) - } - write(t, root, "packages/shared lib/shared.go", "package shared\nconst Value = 99\n") - var out bytes.Buffer - if err := (Service{}).run(context.Background(), in, filepath.Join(root, "services/api"), []string{"go", "run", "-buildvcs=false", "."}, os.Environ(), &out, &out); err != nil { - t.Fatalf("%v: %s", err, &out) - } - if strings.TrimSpace(out.String()) != "99" { - t.Fatalf("not using local library: %s", &out) - } -} - -func TestGoUnresolvedImportFailsWithoutTidy(t *testing.T) { - root := setupGo(t) - module := "module example.com/api\ngo 1.25.0\n" - write(t, root, "api/go.mod", module) - write(t, root, "api/main.go", "package main\nimport _ \"example.invalid/missing\"\nfunc main(){}\n") - in := Input{Root: root, Manifest: &workspace.Manifest{Projects: []workspace.ManifestProject{project("api", "api", "go")}}} - err := (Service{}).Prepare(context.Background(), in) - if err == nil || !strings.Contains(err.Error(), "example.invalid/missing") { - t.Fatalf("error = %v", err) - } - b, _ := os.ReadFile(filepath.Join(root, "api/go.mod")) - if string(b) != module { - t.Fatal("implicit tidy changed go.mod") - } -} - -func TestGoWorkspaceAcceptsSymlinkPaths(t *testing.T) { - for _, mode := range []string{"workspace-root", "gowork", "gowork-parent", "pwd-alias"} { - t.Run(mode, func(t *testing.T) { - root := setupGo(t) - write(t, root, "go.work", "go 1.25.0\nuse ./api\n") - write(t, root, "api/go.mod", "module example.com/api\ngo 1.25.0\n") - write(t, root, "api/main.go", "package main\nfunc main(){}\n") - physicalRoot, err := filepath.EvalSymlinks(root) - if err != nil { - t.Fatal(err) - } - linkTarget := filepath.Join(physicalRoot, "go.work") - alias := filepath.Join(physicalRoot, "alias.work") - if mode != "gowork" { - linkTarget = physicalRoot - alias = filepath.Join(t.TempDir(), "workspace-link") - } - if err := os.Symlink(linkTarget, alias); err != nil { - if runtime.GOOS == "windows" { - t.Skipf("symlinks unavailable: %v", err) - } - t.Fatal(err) - } - active := alias - switch mode { - case "workspace-root": - root, active = alias, filepath.Join(physicalRoot, "go.work") - case "gowork-parent": - active = filepath.Join(alias, "go.work") - case "pwd-alias": - active = filepath.Join(physicalRoot, "go.work") - t.Setenv("PWD", filepath.Join(alias, "api")) - } - t.Setenv("GOWORK", active) - in := Input{Root: root, Manifest: &workspace.Manifest{Projects: []workspace.ManifestProject{project("api", "api", "go")}}} - if err := (Service{}).Prepare(context.Background(), in); err != nil { - t.Fatal(err) - } - }) - } -} - -func TestGoWorkspaceRejectsExternalFileWithSameContents(t *testing.T) { - root := setupGo(t) - work := "go 1.25.0\n" - write(t, root, "go.work", work) - write(t, root, "api/go.mod", "module example.com/api\ngo 1.25.0\n") - external := t.TempDir() - write(t, external, "go.work", work) - active := filepath.Join(external, "go.work") - t.Setenv("GOWORK", active) - in := Input{Root: root, Manifest: &workspace.Manifest{Projects: []workspace.ManifestProject{project("api", "api", "go")}}} - if err := (Service{}).Prepare(context.Background(), in); err == nil || !strings.Contains(err.Error(), "uses external GOWORK="+active) { - t.Fatalf("expected external workspace rejection, got %v", err) - } -} - -type fakeProvider struct{ dir string } - -func (p fakeProvider) Prepare(_ context.Context, c runtimeport.Command) (runtimeport.Command, error) { - c.Argv = append([]string{"wrapped"}, c.Argv...) - c.Directory = p.dir - c.Env = append(c.Env, "PREPARED_ENV=present") - return c, nil -} -func (p fakeProvider) PrepareCLI(ctx context.Context, c runtimeport.Command) (runtimeport.Command, error) { - return p.Prepare(ctx, c) -} - -func TestRuntimePreparedDirectoryArgumentsAndEnvironmentAreForwarded(t *testing.T) { - dir := t.TempDir() - s := Service{Provider: fakeProvider{dir}, Run: func(_ context.Context, c runtimeport.Command, _, _ io.Writer) error { - if c.Directory != dir || strings.Join(c.Argv, " ") != "wrapped go version" || !strings.Contains(strings.Join(c.Env, "\n"), "PREPARED_ENV=present") { - t.Fatalf("prepared command lost: %+v", c) - } - return nil - }} - if err := s.run(context.Background(), Input{Runtime: runtimeport.Mise}, "before", []string{"go", "version"}, nil, io.Discard, io.Discard); err != nil { - t.Fatal(err) - } -} - -func TestNodeRootInstallDeduplicatedAndInvalidated(t *testing.T) { - t.Setenv("XDG_CACHE_HOME", t.TempDir()) - root := t.TempDir() - write(t, root, "package.json", `{"packageManager":"pnpm@12.3.4"}`) - write(t, root, "apps/a/package.json", `{"dependencies":{"dependency":"1.0.0"}}`) - write(t, root, "apps/b/package.json", `{}`) - in := Input{Root: root, Manifest: &workspace.Manifest{Projects: []workspace.ManifestProject{project("a", "apps/a", "node"), project("b", "apps/b", "node")}}} - installs := 0 - s := Service{Run: func(_ context.Context, c runtimeport.Command, out, _ io.Writer) error { - if c.Directory != root { - t.Fatalf("install outside root: %s", c.Directory) - } - if c.Argv[1] == "--version" { - fmt.Fprintln(out, "12.3.4") - return nil - } - installs++ - if err := os.MkdirAll(filepath.Join(root, "node_modules/dependency"), 0o755); err != nil { - return err - } - return nil - }} - for i := 0; i < 2; i++ { - if err := s.Prepare(context.Background(), in); err != nil { - t.Fatal(err) - } - } - if installs != 1 { - t.Fatalf("installs=%d", installs) - } - write(t, root, "apps/b/package.json", `{"scripts":{"dev":"updated"}}`) - if err := s.Prepare(context.Background(), in); err != nil { - t.Fatal(err) - } - if installs != 2 { - t.Fatal("package change ignored") - } - if err := os.RemoveAll(filepath.Join(root, "node_modules")); err != nil { - t.Fatal(err) - } - if err := s.Prepare(context.Background(), in); err != nil { - t.Fatal(err) - } - if installs != 3 { - t.Fatal("cleared dependencies ignored") - } -} - -func TestCancellationAndFailedPreparationPermitRetry(t *testing.T) { - root := t.TempDir() - write(t, root, "api/go.mod", "module example.com/api\ngo 1.25.0\n") - in := Input{Root: root, Manifest: &workspace.Manifest{Projects: []workspace.ManifestProject{project("api", "api", "go")}}} - started := make(chan struct{}) - var once sync.Once - s := Service{Run: func(ctx context.Context, c runtimeport.Command, out, _ io.Writer) error { - if c.Argv[1] == "env" { - return nil - } - once.Do(func() { close(started) }) - <-ctx.Done() - return ctx.Err() - }} - ctx, cancel := context.WithCancel(context.Background()) - done := make(chan error, 1) - go func() { done <- s.Prepare(ctx, in) }() - select { - case <-started: - case <-time.After(5 * time.Second): - t.Fatal("preparation did not start") - } - cancel() - select { - case err := <-done: - if err == nil { - t.Fatal("cancel succeeded") - } - case <-time.After(5 * time.Second): - t.Fatal("cancel blocked") - } - s.Run = func(context.Context, runtimeport.Command, io.Writer, io.Writer) error { return nil } - if err := s.Prepare(context.Background(), in); err != nil { - t.Fatalf("retry could not acquire lock: %v", err) - } -} - -func TestNodeLockfileMismatchUsesFrozenInstallAndStops(t *testing.T) { - t.Setenv("XDG_CACHE_HOME", t.TempDir()) - root := t.TempDir() - write(t, root, "package.json", `{"packageManager":"pnpm@12.3.4"}`) - write(t, root, "pnpm-lock.yaml", "existing lock\n") - in := Input{Root: root, Manifest: &workspace.Manifest{Projects: []workspace.ManifestProject{project("web", "apps/web", "node"), project("api", "services/api", "go")}}} - s := Service{Run: func(_ context.Context, c runtimeport.Command, out, _ io.Writer) error { - if c.Argv[0] == "go" { - t.Fatal("continued after failed install") - } - if c.Argv[1] == "--version" { - fmt.Fprintln(out, "12.3.4") - return nil - } - if strings.Join(c.Argv, " ") != "pnpm install --frozen-lockfile" { - t.Fatalf("command=%v", c.Argv) - } - return errors.New("lockfile mismatch") - }} - if err := s.Prepare(context.Background(), in); err == nil { - t.Fatal("expected install failure") - } -} - -func TestFinitePNPMTasksNeverCreateAnApplicationLockfile(t *testing.T) { - root := t.TempDir() - environment := "---\nlockfileVersion: '9.0'\nimporters:\n .:\n configDependencies: {}\n packageManagerDependencies:\n pnpm: {specifier: 12.3.4, version: 12.3.4}\n" - graph := "---\nlockfileVersion: '9.0'\nimporters:\n .: {}\n apps/web:\n dependencies:\n react: {specifier: '19', version: '19.3.0'}\n" - for _, fixture := range []struct{ body, flag string }{ - {environment, "--frozen-lockfile"}, - {environment + "\n---\n", "--frozen-lockfile"}, - {environment + graph, "--frozen-lockfile"}, - {graph + environment, "--frozen-lockfile"}, - {"not valid: [", "--frozen-lockfile"}, - } { - write(t, root, "pnpm-lock.yaml", fixture.body) - args := NodeInstallCommand(root, "pnpm") - if args[len(args)-1] != fixture.flag { - t.Fatalf("lock=%s args=%v", fixture.body, args) - } - } -} - -// Build selection must include libraries with no development process. -func TestExplicitProjectsPrepareGoLibrariesWithoutDev(t *testing.T) { - root := t.TempDir() - write(t, root, "go.work", "go 1.25.0\nuse ./packages/lib\n") - p := workspace.ManifestProject{Name: "lib", RelativeDir: "packages/lib", Toolchain: "go"} - write(t, root, "packages/lib/go.mod", "module example.com/lib\ngo 1.25.0\n") - calls := []string{} - service := Service{Run: func(_ context.Context, cmd runtimeport.Command, out, _ io.Writer) error { - calls = append(calls, strings.Join(cmd.Argv, " ")) - if strings.Join(cmd.Argv, " ") == "go env GOWORK" { - fmt.Fprintln(out, filepath.Join(root, "go.work")) - } - return nil - }} - in := Input{Root: root, Manifest: &workspace.Manifest{Projects: []workspace.ManifestProject{p}}, Projects: []string{"lib"}, Runtime: runtimeport.Builtin} - if err := service.Prepare(context.Background(), in); err != nil { - t.Fatal(err) - } - if len(calls) != 2 || calls[1] != "go list -mod=readonly -buildvcs=false -deps ./..." { - t.Fatal(calls) - } - calls = nil - in.Projects = []string{} - if err := service.Prepare(context.Background(), in); err != nil || len(calls) != 0 { - t.Fatalf("empty selection: %v %v", calls, err) - } -} diff --git a/packages/cli/internal/modules/devservice/manager_test.go b/packages/cli/internal/modules/devservice/manager_test.go deleted file mode 100644 index 8b147c6c..00000000 --- a/packages/cli/internal/modules/devservice/manager_test.go +++ /dev/null @@ -1,219 +0,0 @@ -package devservice - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net" - "os" - "os/exec" - "os/signal" - "strings" - "sync" - "testing" - "time" -) - -func TestServiceProcessHelper(t *testing.T) { - mode := os.Getenv("ONE_TEST_DEV_SERVICE") - if mode == "" { - return - } - if mode == "child" { - cmd := exec.Command(os.Args[0], "-test.run=^TestServiceProcessHelper$") - cmd.Env = append(os.Environ(), "ONE_TEST_DEV_SERVICE=stubborn") - cmd.Stderr = os.Stderr - if err := cmd.Start(); err != nil { - os.Exit(2) - } - } else { - listener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - os.Exit(3) - } - fmt.Fprintln(os.Stderr, "local http://"+listener.Addr().String()+"/") - defer listener.Close() - } - _ = json.NewEncoder(os.Stdout).Encode(Event{Status: "running"}) - if mode == "normal" { - _, _ = io.Copy(io.Discard, os.Stdin) - os.Exit(0) - } - signals := make(chan os.Signal, 1) - signal.Notify(signals, os.Interrupt) - for { - <-signals - if mode == "normal" { - os.Exit(0) - } - } -} -func testManager(t *testing.T, mode string) *Manager { - t.Helper() - m := New() - m.grace = 100 * time.Millisecond - m.command = func(in Input) (*exec.Cmd, error) { - cmd := exec.Command(os.Args[0], "-test.run=^TestServiceProcessHelper$") - cmd.Env = append(os.Environ(), "ONE_TEST_DEV_SERVICE="+mode) - cmd.WaitDelay = time.Second - return cmd, nil - } - t.Cleanup(m.Close) - return m -} -func awaitState(t *testing.T, m *Manager, in Input, check func(Snapshot) bool) Snapshot { - t.Helper() - deadline := time.Now().Add(6 * time.Second) - for time.Now().Before(deadline) { - s := m.Get(in.Root, in.Project, "", 0) - if check(s) { - return s - } - time.Sleep(10 * time.Millisecond) - } - t.Fatalf("unexpected service state: %+v", m.Get(in.Root, in.Project, "", 0)) - return Snapshot{} -} -func TestLifecycleDeduplicatesRestartsAndReleasesPort(t *testing.T) { - for _, mode := range []string{"normal", "child"} { - t.Run(mode, func(t *testing.T) { - m := testManager(t, mode) - in := Input{Root: t.TempDir(), Project: "web", Environment: "dev"} - var wg sync.WaitGroup - ids := make(chan string, 16) - for i := 0; i < 16; i++ { - wg.Add(1) - go func() { - defer wg.Done() - s, err := m.Start(in) - if err != nil { - t.Error(err) - } - ids <- s.ID - }() - } - wg.Wait() - close(ids) - for id := range ids { - if id != "1" { - t.Errorf("duplicate run: %s", id) - } - } - first := awaitState(t, m, in, func(s Snapshot) bool { - return s.Status == "running" && len(s.Endpoints) > 0 && s.Endpoints[0].Reachable - }) - in.Environment = "prod" - if _, err := m.Restart(in); err != nil { - t.Fatal(err) - } - second := awaitState(t, m, in, func(s Snapshot) bool { - return s.ID != first.ID && s.Status == "running" && len(s.Endpoints) > 0 && s.Endpoints[0].Reachable - }) - if second.Environment != "prod" { - t.Fatal(second) - } - m.Stop(in.Root, in.Project) - awaitState(t, m, in, func(s Snapshot) bool { return s.Status == "stopped" }) - for _, endpoint := range append(first.Endpoints, second.Endpoints...) { - conn, err := net.DialTimeout("tcp", strings.TrimSuffix(strings.TrimPrefix(endpoint.URL, "http://"), "/"), 100*time.Millisecond) - if err == nil { - conn.Close() - t.Fatal("service child port survived shutdown") - } - } - }) - } -} -func TestStopAndCloseCancelQueuedPreparation(t *testing.T) { - m := testManager(t, "normal") - in := Input{Root: t.TempDir(), Project: "web"} - gate := make(chan struct{}, 1) - gate <- struct{}{} - m.gates[key(in.Root, "")] = gate - first, _ := m.Start(in) - m.Stop(in.Root, in.Project) - awaitState(t, m, in, func(s Snapshot) bool { return s.Status == "stopped" }) - second, _ := m.Start(in) - if first.ID == second.ID { - t.Fatal("run not replaced") - } - m.Close() - if _, err := m.Start(in); err == nil { - t.Fatal("started after close") - } -} -func TestLogsBoundedCursorAndWorkspaceIsolation(t *testing.T) { - m := New() - r := &run{input: Input{Root: t.TempDir(), Project: "web"}, state: Snapshot{ID: "1", NextSeq: 1}} - m.runs[key(r.input.Root, r.input.Project)] = r - for i := 0; i < 100; i++ { - m.append(r, strings.Repeat("hello", 1000)) - } - s := m.Get(r.input.Root, "web", "", 0) - size := 0 - for _, line := range s.Logs { - size += len(line.Text) - } - if size > maxLogBytes || s.FirstSeq <= 1 { - t.Fatal("unbounded logs") - } - m.append(r, "tail\n") - delta := m.Get(r.input.Root, "web", s.ID, s.NextSeq-1) - if len(delta.Logs) != 1 || delta.Logs[0].Text != "tail\n" { - t.Fatal(delta) - } - if got := m.Get(t.TempDir(), "web", "", 0); got.ID != "" { - t.Fatal("workspace isolation failed") - } - if got := m.Get(r.input.Root, "web", "old-run", 99999); len(got.Logs) == 0 { - t.Fatal("new run did not reset cursor") - } -} -func TestDiscoverOnlyLocalEndpoints(t *testing.T) { - urls := discoverURLs("\x1b[32mhttp://0.0.0.0:3000/\x1b[0m\nhttps://docs.example.com http://localhost:3000/?token=secret http://user:pw@127.0.0.1:3000/") - if len(urls) != 1 || urls[0] != "http://127.0.0.1:3000/" { - t.Fatal(urls) - } - m := New() - r := &run{state: Snapshot{Endpoints: []Endpoint{{URL: "http://127.0.0.1:1/"}}, Status: "running"}} - ctx, cancel := context.WithCancel(context.Background()) - cancel() - m.probe(ctx, r) - if r.state.Endpoints[0].Reachable { - t.Fatal("closed port is reachable") - } -} - -func TestLogsPreserveUTF8AcrossWrites(t *testing.T) { - m := New() - r := &run{state: Snapshot{NextSeq: 1}} - raw := []byte("中文开发服务\n") - for _, b := range raw { - m.append(r, string([]byte{b})) - } - var combined strings.Builder - for _, line := range r.state.Logs { - combined.WriteString(line.Text) - } - if combined.String() != string(raw) { - t.Fatal(combined.String()) - } -} - -func TestPartialURLIsNotPublished(t *testing.T) { - m := New() - r := &run{state: Snapshot{NextSeq: 1}} - m.append(r, "http://localhost:3") - if len(r.state.Endpoints) != 0 { - t.Fatal("partial port was published") - } - m.append(r, "000/?token=test\n") - if len(r.state.Endpoints) != 0 { - t.Fatal("credential URL was published") - } - m.append(r, "http://localhost:4000/\n") - if len(r.state.Endpoints) != 1 || r.state.Endpoints[0].URL != "http://localhost:4000/" { - t.Fatal(r.state.Endpoints) - } -} diff --git a/packages/cli/internal/modules/environment/binding_session_test.go b/packages/cli/internal/modules/environment/binding_session_test.go deleted file mode 100644 index 63b906d8..00000000 --- a/packages/cli/internal/modules/environment/binding_session_test.go +++ /dev/null @@ -1,174 +0,0 @@ -package environment - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - "net/http/httptest" - "os" - "strings" - "sync/atomic" - "testing" - "time" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" - session "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/infisicalsession" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" - "github.com/zalando/go-keyring" -) - -func TestWorkspaceBindingRejectsSessionChangesWithoutPublishing(t *testing.T) { - for _, stage := range []string{"create", "metadata", "verification"} { - for _, change := range []string{"unchanged", "logout", "account", "organization", "site", "token", "expiry"} { - t.Run(stage+"/"+change, func(t *testing.T) { - keyring.MockInit() - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - scope := unboundScope(t) - root := scope.WorkingDirectory() - manifest, err := workspace.ReadManifest(root) - if err != nil { - t.Fatal(err) - } - if stage == "create" { - // An interrupted creation must not back-fill an older manifest's - // identity before rejecting the changed session. - manifest.Workspace = nil - } else { - manifest.Env = &workspace.EnvironmentConfig{ProjectID: "previous", Environments: []string{"dev"}} - } - if err := workspace.WriteManifest(root, manifest); err != nil { - t.Fatal(err) - } - before, err := os.ReadFile(workspace.ManifestPath(root)) - if err != nil { - t.Fatal(err) - } - _, revision, err := workspace.ReadManifestSnapshot(root) - if err != nil { - t.Fatal(err) - } - var foreignRequests, creates, verifies atomic.Int32 - foreign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - foreignRequests.Add(1) - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"secrets":[]}`) - })) - defer foreign.Close() - var original session.Session - store := func(current session.Session) error { - raw, err := json.Marshal(current) - if err != nil { - return err - } - return keyring.Set("one-cli.infisical", "session", string(raw)) - } - var changed atomic.Bool - changeSession := func() { - if !changed.CompareAndSwap(false, true) || change == "unchanged" { - return - } - if change == "logout" { - if err := session.Logout(); err != nil { - t.Error(err) - } - return - } - current := original - switch change { - case "account": - current.UserID = "another-user" - case "organization": - current.OrganizationID = "another-org" - case "site": - current.SiteURL = foreign.URL - case "token": - current.Token = "replacement-test-token" - case "expiry": - current.ExpiresAt = time.Now().Add(-time.Minute) - } - if err := store(current); err != nil { - t.Error(err) - } - } - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if r.Header.Get("Authorization") != "Bearer original-test-token" { - t.Error("binding used a different session for remote work") - } - switch r.URL.Path { - case "/api/v2/workspace": - creates.Add(1) - changeSession() - fmt.Fprint(w, `{"project":{"id":"remote","name":"demo"}}`) - case "/api/v1/workspace/remote": - if stage == "metadata" { - changeSession() - } - fmt.Fprint(w, `{"workspace":{"id":"remote","name":"demo","type":"secret-manager","orgId":"org","environments":[{"slug":"dev"}]}}`) - case "/api/v3/secrets/raw": - verifies.Add(1) - if stage == "verification" { - changeSession() - } - fmt.Fprint(w, `{"secrets":[]}`) - default: - t.Errorf("unexpected binding request: %s", r.URL.Path) - http.NotFound(w, r) - } - })) - defer upstream.Close() - original = session.Session{ - Info: session.Info{SiteURL: upstream.URL, UserID: "user", OrganizationID: "org", ExpiresAt: time.Now().Add(time.Hour)}, - Token: "original-test-token", - } - if err := store(original); err != nil { - t.Fatal(err) - } - input := BindWorkspaceInput{Scope: scope, Create: stage == "create", Revision: revision} - if stage != "create" { - input.ProjectID = "remote" - } - service := newTestService(t) - result, err := service.BindWorkspace(context.Background(), input) - if foreignRequests.Load() != 0 || (stage != "create" && verifies.Load() != 1) { - t.Fatal("verification did not stay on the original session and site") - } - if change == "unchanged" { - if err != nil || result.ProjectID != "remote" { - t.Fatalf("unchanged session failed to bind: %+v %v", result, err) - } - return - } - wantCode := "INFISICAL_AUTH_FAILED" - if change == "logout" { - wantCode = "INFISICAL_AUTH_MISSING" - } - var coded *output.Error - if !errors.As(err, &coded) || coded.Code != wantCode { - t.Fatalf("session change lost its authentication error: %v", err) - } - if strings.Contains(err.Error(), original.Token) || strings.Contains(err.Error(), "replacement-test-token") { - t.Fatal("session failure exposed a token") - } - after, readErr := os.ReadFile(workspace.ManifestPath(root)) - if readErr != nil || string(before) != string(after) { - t.Fatalf("session change published local changes: %v", readErr) - } - if stage == "create" { - if coded.Context["project_id"] != "remote" || coded.Context["partial_state"] != "project_created_binding_unsaved" { - t.Fatalf("lost the created project for recovery: %+v", coded.Context) - } - if err := store(original); err != nil { - t.Fatal(err) - } - result, err = service.BindWorkspace(context.Background(), BindWorkspaceInput{Scope: scope, ProjectID: "remote", Revision: revision}) - if err != nil || result.ProjectID != "remote" || creates.Load() != 1 { - t.Fatalf("recovery failed or created a duplicate project: %+v %v", result, err) - } - } - }) - } - } -} diff --git a/packages/cli/internal/modules/environment/binding_test.go b/packages/cli/internal/modules/environment/binding_test.go deleted file mode 100644 index 1ece4249..00000000 --- a/packages/cli/internal/modules/environment/binding_test.go +++ /dev/null @@ -1,323 +0,0 @@ -package environment - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "sync" - "testing" - "time" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/env/infisical" - "github.com/torchstellar-team/one-cli/packages/cli/internal/application/execution" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" - session "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/infisicalsession" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" - "github.com/zalando/go-keyring" -) - -func unboundScope(t *testing.T) execution.Scope { - t.Helper() - root := filepath.Join(t.TempDir(), "demo") - if err := os.Mkdir(root, 0755); err != nil { - t.Fatal(err) - } - if err := workspace.WriteManifest(root, &workspace.Manifest{Version: workspace.ManifestVersion, Workspace: &workspace.ManifestWorkspace{ID: workspace.GenerateProjectID("demo"), Name: "demo"}}); err != nil { - t.Fatal(err) - } - return execution.NewScope(context.Background(), root) -} - -func TestReadsAndDeletesNeverInitializeOrModifyAnUnboundWorkspace(t *testing.T) { - service := newTestService(t) - service.initInfisical = func(context.Context, string, infisical.InitInput) (*infisical.InitResult, error) { - t.Fatal("read/delete initialized storage") - return nil, nil - } - scope := unboundScope(t) - before, _ := os.ReadFile(workspace.ManifestPath(scope.WorkingDirectory())) - for _, operation := range []func() error{ - func() error { _, err := service.PlanSet(PlanSetInput{Scope: scope}); return err }, - func() error { _, err := service.List(context.Background(), ListInput{Scope: scope}); return err }, - func() error { - _, err := service.Get(context.Background(), GetInput{Scope: scope, Key: "TOKEN"}) - return err - }, - func() error { - _, err := service.Delete(context.Background(), DeleteInput{Scope: scope, Key: "TOKEN"}) - return err - }, - } { - err := operation() - var coded *output.Error - if !errors.As(err, &coded) || coded.Code != "INFISICAL_NOT_CONFIGURED" { - t.Fatalf("error=%v", err) - } - } - after, _ := os.ReadFile(workspace.ManifestPath(scope.WorkingDirectory())) - if string(before) != string(after) { - t.Fatal("query changed workspace") - } -} - -func mockSession(t *testing.T, site string) { - t.Helper() - keyring.MockInit() - raw, _ := json.Marshal(session.Session{Info: session.Info{UserID: "test", SiteURL: site, ExpiresAt: time.Now().Add(time.Hour)}, Token: "test-token"}) - if err := keyring.Set("one-cli.infisical", "session", string(raw)); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = keyring.Delete("one-cli.infisical", "session") }) -} - -func TestWorkspaceBindingRejectsStaleRevisionBeforeRemoteWork(t *testing.T) { - service := newTestService(t) - scope := unboundScope(t) - service.initInfisical = func(context.Context, string, infisical.InitInput) (*infisical.InitResult, error) { - t.Fatal("stale binding contacted Infisical") - return nil, nil - } - _, err := service.BindWorkspace(context.Background(), BindWorkspaceInput{Scope: scope, Create: true, Revision: "stale"}) - var coded *output.Error - if !errors.As(err, &coded) || coded.Code != "SERVE_MANIFEST_CONFLICT" { - t.Fatalf("expected revision conflict, got %v", err) - } -} - -func TestWorkspaceBindingPreservesExternalEditDuringCreation(t *testing.T) { - service := newTestService(t) - scope := unboundScope(t) - root := scope.WorkingDirectory() - _, revision, err := workspace.ReadManifestSnapshot(root) - if err != nil { - t.Fatal(err) - } - creates := 0 - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch r.URL.Path { - case "/api/v2/workspace": - creates++ - manifest, err := workspace.ReadManifest(root) - if err != nil { - t.Error(err) - } - manifest.Workspace.Name = "edited-during-creation" - if err := workspace.WriteManifest(root, manifest); err != nil { - t.Error(err) - } - fmt.Fprint(w, `{"project":{"id":"created","name":"demo"}}`) - case "/api/v1/workspace/created": - fmt.Fprint(w, `{"workspace":{"id":"created","name":"demo","type":"secret-manager","environments":[{"slug":"dev"}]}}`) - case "/api/v3/secrets/raw": - fmt.Fprint(w, `{"secrets":[]}`) - default: - t.Errorf("unexpected request: %s", r.URL) - } - })) - defer upstream.Close() - mockSession(t, upstream.URL) - _, err = service.BindWorkspace(context.Background(), BindWorkspaceInput{Scope: scope, Create: true, Revision: revision}) - var coded *output.Error - if !errors.As(err, &coded) || coded.Code != "SERVE_MANIFEST_CONFLICT" || coded.Context["project_id"] != "created" { - t.Fatalf("expected recoverable created project, got %v", err) - } - manifest, current, err := workspace.ReadManifestSnapshot(root) - if err != nil || manifest.Workspace.Name != "edited-during-creation" || manifest.Env != nil { - t.Fatalf("binding overwrote external edits: %+v %v", manifest, err) - } - result, err := service.BindWorkspace(context.Background(), BindWorkspaceInput{Scope: scope, ProjectID: "created", Revision: current}) - if err != nil || result.ProjectID != "created" || creates != 1 { - t.Fatalf("recovery created another project: %+v %v, creations=%d", result, err, creates) - } -} - -func TestSameNamedWorkspacesCreateDistinctProjectsAndRetryUsesBinding(t *testing.T) { - var mu sync.Mutex - names := map[string]string{} - values := map[string]string{} - writes := map[string]int{} - failSave := false - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - mu.Lock() - defer mu.Unlock() - w.Header().Set("Content-Type", "application/json") - if r.Method == http.MethodPost && r.URL.Path == "/api/v2/workspace" { - var body struct { - Name string `json:"projectName"` - } - _ = json.NewDecoder(r.Body).Decode(&body) - if _, exists := names[body.Name]; exists { - w.WriteHeader(409) - fmt.Fprint(w, `{"message":"name already exists"}`) - return - } - id := fmt.Sprintf("remote-%d", len(names)+1) - names[body.Name] = id - _ = json.NewEncoder(w).Encode(map[string]any{"project": map[string]string{"id": id, "name": body.Name}}) - return - } - if r.Method == http.MethodGet { - id := r.URL.Query().Get("workspaceId") - if value, ok := values[id]; ok { - _ = json.NewEncoder(w).Encode(map[string]any{"secret": map[string]string{"secretKey": "TOKEN", "secretValue": value}}) - return - } - w.WriteHeader(404) - fmt.Fprint(w, `{"message":"not found"}`) - return - } - var body map[string]any - _ = json.NewDecoder(r.Body).Decode(&body) - id, _ := body["workspaceId"].(string) - if id == "" { - t.Errorf("missing project ID: %v", body) - w.WriteHeader(400) - return - } - if failSave { - w.WriteHeader(403) - fmt.Fprint(w, `{"message":"forbidden"}`) - return - } - value, _ := body["secretValue"].(string) - values[id] = value - writes[id]++ - _ = json.NewEncoder(w).Encode(map[string]any{"secret": map[string]string{"secretKey": "TOKEN", "secretValue": value}}) - })) - defer server.Close() - mockSession(t, server.URL) - service := newTestService(t) - scopes := []execution.Scope{unboundScope(t), unboundScope(t)} - ids := []string{} - for index, scope := range scopes { - binding, err := service.BindWorkspace(context.Background(), BindWorkspaceInput{Scope: scope, Create: true}) - if err != nil { - t.Fatal(err) - } - plan, err := service.PlanSet(PlanSetInput{Scope: scope}) - if err != nil { - t.Fatal(err) - } - _, err = service.Set(context.Background(), SetInput{Plan: plan, Key: "TOKEN", Value: fmt.Sprint(index)}) - if err != nil { - t.Fatal(err) - } - if !binding.Created { - t.Fatalf("binding=%+v", binding) - } - ids = append(ids, binding.ProjectID) - if index == 1 && (binding.RequestedName != "demo" || !strings.HasPrefix(binding.ProjectName, "demo-")) { - t.Fatalf("collision metadata=%+v", binding) - } - again, err := service.BindWorkspace(context.Background(), BindWorkspaceInput{Scope: scope, Create: true}) - if err != nil || again.Created || again.ProjectID != binding.ProjectID { - t.Fatalf("repeat binding=%+v,%v", again, err) - } - if _, err := service.Set(context.Background(), SetInput{Plan: plan, Key: "TOKEN", Value: fmt.Sprint(index)}); err != nil { - t.Fatal(err) - } - } - if ids[0] == ids[1] || len(names) != 2 || writes[ids[0]] != 1 || writes[ids[1]] != 1 { - t.Fatalf("project routing: ids=%v names=%v writes=%v", ids, names, writes) - } - // A copied manifest deliberately shares its saved ID. Overwriting requires - // confirmation and names that actual target, even in another local folder. - copyScope := unboundScope(t) - original, err := os.ReadFile(workspace.ManifestPath(scopes[0].WorkingDirectory())) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(workspace.ManifestPath(copyScope.WorkingDirectory()), original, 0644); err != nil { - t.Fatal(err) - } - copyPlan, err := service.PlanSet(PlanSetInput{Scope: copyScope}) - if err != nil { - t.Fatal(err) - } - _, err = service.Set(context.Background(), SetInput{Plan: copyPlan, Key: "TOKEN", Value: "changed"}) - var overwrite *output.Error - if !errors.As(err, &overwrite) || overwrite.Code != "ENV_SET_OVERWRITE_REQUIRED" || overwrite.Context["project_id"] != ids[0] || !strings.Contains(err.Error(), ids[0]) { - t.Fatalf("copied binding error=%v", err) - } - if len(names) != 2 || writes[ids[0]] != 1 { - t.Fatal("copy unexpectedly created or changed a project") - } - // A failed variable write must preserve a successfully created binding. - mu.Lock() - failSave = true - mu.Unlock() - scope := unboundScope(t) - bound, err := service.BindWorkspace(context.Background(), BindWorkspaceInput{Scope: scope, Create: true}) - if err != nil { - t.Fatal(err) - } - plan, err := service.PlanSet(PlanSetInput{Scope: scope}) - if err != nil { - t.Fatal(err) - } - _, err = service.Set(context.Background(), SetInput{Plan: plan, Key: "TOKEN", Value: "third"}) - var coded *output.Error - if !errors.As(err, &coded) { - t.Fatalf("variable write failure=%v", err) - } - manifest, readErr := workspace.ReadManifest(scope.WorkingDirectory()) - if readErr != nil || manifest.Env.ProjectID != bound.ProjectID { - t.Fatalf("failed variable write changed binding: %+v %v", manifest, readErr) - } - mu.Lock() - failSave = false - mu.Unlock() - retried, err := service.Set(context.Background(), SetInput{Plan: plan, Key: "TOKEN", Value: "third"}) - if err != nil || len(names) != 3 { - t.Fatalf("retry=%+v,%v; projects=%v", retried, err, names) - } -} - -func TestBindingInitializationIsSerializedAndMalformedConfigIsRejected(t *testing.T) { - mockSession(t, "http://127.0.0.1:1") - service := newTestService(t) - scope := unboundScope(t) - calls := 0 - service.initInfisical = func(_ context.Context, root string, _ infisical.InitInput) (*infisical.InitResult, error) { - calls++ - manifest, err := workspace.ReadManifest(root) - if err != nil { - return nil, err - } - manifest.Env = &workspace.EnvironmentConfig{ProjectID: "remote", Environments: []string{"dev", "staging", "prod"}} - if err := workspace.WriteManifest(root, manifest); err != nil { - return nil, err - } - return &infisical.InitResult{ProjectID: "remote", ProjectName: "demo", Created: true}, nil - } - var wg sync.WaitGroup - for range 3 { - wg.Add(1) - go func() { - defer wg.Done() - if _, err := service.BindWorkspace(context.Background(), BindWorkspaceInput{Scope: scope, Create: true}); err != nil { - t.Error(err) - } - }() - } - wg.Wait() - if calls != 1 { - t.Fatalf("initialized %d times", calls) - } - if err := os.WriteFile(workspace.ManifestPath(scope.WorkingDirectory()), []byte("broken"), 0644); err != nil { - t.Fatal(err) - } - if _, err := service.BindWorkspace(context.Background(), BindWorkspaceInput{Scope: scope, Create: true}); err == nil { - t.Fatal("malformed config accepted") - } - if calls != 1 { - t.Fatal("malformed config triggered creation") - } -} diff --git a/packages/cli/internal/modules/environment/service_test.go b/packages/cli/internal/modules/environment/service_test.go deleted file mode 100644 index 283d1511..00000000 --- a/packages/cli/internal/modules/environment/service_test.go +++ /dev/null @@ -1,113 +0,0 @@ -package environment - -import ( - "context" - "os" - "path/filepath" - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/application/execution" - catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" -) - -func TestServiceResolvesScopeFromManifest(t *testing.T) { - t.Parallel() - root := t.TempDir() - if err := workspace.WriteManifest(root, &workspace.Manifest{ - Version: workspace.ManifestVersion, - - Env: &workspace.EnvironmentConfig{ProjectID: "remote", Environments: []string{"dev", "production"}}, - }); err != nil { - t.Fatal(err) - } - service := newTestService(t) - base := execution.NewScope(context.Background(), root) - resolution, err := service.resolve(resolveInput{ - Scope: base, Requested: "production", Capability: catalog.CapabilityEnvGet, Verb: "get", - }) - if err != nil { - t.Fatal(err) - } - if resolution.Scope.WorkspaceRoot() != root || - resolution.Scope.Backend().String() != "env/infisical" || - resolution.Scope.Environment() != "production" { - t.Fatalf("resolution scope = %#v", resolution.Scope) - } - if base.WorkspaceRoot() != "" || base.Backend().String() != "" { - t.Fatal("environment resolution mutated its parent scope") - } -} - -func TestPlanSetOwnsEnvironmentAndProjectSelection(t *testing.T) { - t.Parallel() - root := t.TempDir() - projectDir := filepath.Join(root, "apps", "web") - if err := os.MkdirAll(projectDir, 0o755); err != nil { - t.Fatal(err) - } - if err := workspace.WriteManifest(root, &workspace.Manifest{ - Version: workspace.ManifestVersion, - - Env: &workspace.EnvironmentConfig{ProjectID: "remote", Environments: []string{"dev"}}, - - Projects: []workspace.ManifestProject{{Name: "web", RelativeDir: "apps/web", Toolchain: "node"}}, - }); err != nil { - t.Fatal(err) - } - service := newTestService(t) - plan, err := service.PlanSet(PlanSetInput{ - Scope: execution.NewScope(context.Background(), root), Environment: "qa", - }) - if err != nil { - t.Fatal(err) - } - if plan.Environment != "qa" || !plan.NeedsEnvironmentCreation || - len(plan.ProjectChoices) != 1 || plan.ProjectChoices[0] != "web" { - t.Fatalf("root plan = %#v", plan) - } - selected := plan.WithProject("web") - if selected.project != "web" || len(selected.ProjectChoices) != 0 { - t.Fatalf("selected plan = %#v", selected) - } - - cwdPlan, err := service.PlanSet(PlanSetInput{ - Scope: execution.NewScope(context.Background(), projectDir), Environment: "dev", - }) - if err != nil { - t.Fatal(err) - } - if cwdPlan.NeedsEnvironmentCreation || cwdPlan.project != "web" || len(cwdPlan.ProjectChoices) != 0 { - t.Fatalf("project plan = %#v", cwdPlan) - } -} - -func TestServiceRejectsMissingCapability(t *testing.T) { - t.Parallel() - root := t.TempDir() - if err := workspace.WriteManifest(root, &workspace.Manifest{ - Version: workspace.ManifestVersion, - - Env: &workspace.EnvironmentConfig{ProjectID: "remote", Environments: []string{"dev", "staging", "prod"}}, - }); err != nil { - t.Fatal(err) - } - _, err := newTestService(t).resolve(resolveInput{ - Scope: execution.NewScope(context.Background(), root), - Capability: catalog.Capability("unsupported"), Verb: "unsupported", - }) - if coded, ok := err.(interface{ ErrorCode() string }); !ok || - coded.ErrorCode() != "BACKEND_VERB_NOT_SUPPORTED" { - t.Fatalf("error = %v", err) - } -} - -func newTestService(t *testing.T) *Service { - t.Helper() - backendCatalog := catalog.Builtin() - service, err := NewService(backendCatalog) - if err != nil { - t.Fatal(err) - } - return service -} diff --git a/packages/cli/internal/modules/environment/session_test.go b/packages/cli/internal/modules/environment/session_test.go deleted file mode 100644 index 359dd6be..00000000 --- a/packages/cli/internal/modules/environment/session_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package environment - -import ( - "context" - "encoding/json" - "testing" - "time" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/env/infisical" - "github.com/torchstellar-team/one-cli/packages/cli/internal/application/execution" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" - session "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/infisicalsession" - "github.com/zalando/go-keyring" -) - -func TestSingleSessionUsesFixedSharedRoot(t *testing.T) { - keyring.MockInit() - raw, _ := json.Marshal(session.Session{Info: session.Info{UserID: "user", SiteURL: session.DefaultSiteURL, ExpiresAt: time.Now().Add(time.Hour)}, Token: "one-session"}) - if err := keyring.Set("one-cli.infisical", "session", string(raw)); err != nil { - t.Fatal(err) - } - root := t.TempDir() - if err := workspace.WriteManifest(root, &workspace.Manifest{Version: workspace.ManifestVersion, - - Env: &workspace.EnvironmentConfig{ProjectID: "remote", Environments: []string{"dev"}}, - Projects: []workspace.ManifestProject{{Name: "web", RelativeDir: "apps/web", Toolchain: "node"}, {Name: "api", RelativeDir: "services/api", Toolchain: "node"}}, - }); err != nil { - t.Fatal(err) - } - service := newTestService(t) - cfg, credentials, err := service.resolveInfisical() - if err != nil || credentials.AccessToken != "one-session" || cfg.SiteURL != session.DefaultSiteURL { - t.Fatalf("session resolution failed: %v", err) - } - scope := execution.NewScope(context.Background(), root) - active, err := execution.ResolveWorkspaceScope(scope) - if err != nil { - t.Fatal(err) - } - path, err := service.resolveInfisicalFolderPath(active, &infisical.WorkspaceConfig{SiteURL: session.DefaultSiteURL}, "") - if err != nil || path != "/" { - t.Fatalf("root path = %q, %v", path, err) - } -} diff --git a/packages/cli/internal/modules/gowork/workspace_test.go b/packages/cli/internal/modules/gowork/workspace_test.go deleted file mode 100644 index 4c0832a9..00000000 --- a/packages/cli/internal/modules/gowork/workspace_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package gowork - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/fsutil" -) - -func put(t *testing.T, root, rel, b string) { - t.Helper() - path := filepath.Join(root, rel) - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte(b), 0o644); err != nil { - t.Fatal(err) - } -} - -func TestIncrementalGoWorkspacePreservesUserDirectivesAndExternalMembers(t *testing.T) { - parent := t.TempDir() - root := filepath.Join(parent, "workspace") - put(t, root, "services/api/go.mod", "module example.com/api\ngo 1.24.0\n") - put(t, parent, "external/go.mod", "module example.com/external\ngo 1.25.0\n") - put(t, root, "packages/shared lib/go.mod", "module example.com/shared\ngo 1.25.0\n") - put(t, root, "go.work", "// my workspace\ngo 1.24.0\ntoolchain go1.25.6\nuse (\n ./services/api // keep this\n ../external\n)\nreplace example.com/old => ../external\n") - p := fsutil.NewFilePlan(root) - b, info, err := Build(root, []string{"services/api", "packages/shared lib"}, p.Read) - if err != nil { - t.Fatal(err) - } - for _, fragment := range []string{"// my workspace", "// keep this", "toolchain go1.25.6", "../external", "replace example.com/old => ../external", `"./packages/shared lib"`} { - if !strings.Contains(string(b), fragment) { - t.Fatalf("lost %s: %s", fragment, b) - } - } - if info.Version != "1.25.6" || len(info.Modules) != 3 { - t.Fatalf("info = %+v", info) - } - put(t, root, "go.work", string(b)) - second, _, err := Build(root, []string{"services/api", "packages/shared lib"}, fsutil.NewFilePlan(root).Read) - if err != nil || string(second) != string(b) { - t.Fatalf("not idempotent: %s %v", second, err) - } -} - -func TestDuplicateModulesAndRemovedMembersAreDiagnosed(t *testing.T) { - root := t.TempDir() - put(t, root, "a/go.mod", "module example.com/same\ngo 1.25.0\n") - put(t, root, "b/go.mod", "module example.com/same\ngo 1.25.0\n") - if _, _, err := Build(root, []string{"a", "b"}, fsutil.NewFilePlan(root).Read); err == nil || !strings.Contains(err.Error(), "duplicate module") { - t.Fatalf("error = %v", err) - } - put(t, root, "go.work", "go 1.25.0\nuse ./removed\n") - if _, _, err := Build(root, []string{"a"}, fsutil.NewFilePlan(root).Read); err == nil || !strings.Contains(err.Error(), "missing go.mod") { - t.Fatalf("error = %v", err) - } -} diff --git a/packages/cli/internal/modules/hooks/hooks_test.go b/packages/cli/internal/modules/hooks/hooks_test.go deleted file mode 100644 index 22d2cf6c..00000000 --- a/packages/cli/internal/modules/hooks/hooks_test.go +++ /dev/null @@ -1,314 +0,0 @@ -package hooks - -import ( - "bytes" - "context" - "encoding/json" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/fsutil" -) - -func fixture(t *testing.T) string { - t.Helper() - root := t.TempDir() - // Git exports repository-local paths when running this suite from a hook. - // Each fixture and its linked worktrees must resolve their own Git state. - for _, key := range []string{"GIT_DIR", "GIT_COMMON_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES"} { - t.Setenv(key, "") // Register restoration of the original environment. - if err := os.Unsetenv(key); err != nil { - t.Fatal(err) - } - } - t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) - t.Setenv("GIT_CONFIG_NOSYSTEM", "1") - write(t, root, "one.manifest.toml", `version = 2 - -[workspace] -id = "hooks" -name = "hooks" -`) - cmd := exec.Command("git", "init", "-q", root) - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("git init: %v %s", err, out) - } - return root -} - -func write(t *testing.T, root, path, value string) { - t.Helper() - full := filepath.Join(root, path) - if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(full, []byte(value), 0o644); err != nil { - t.Fatal(err) - } -} - -func read(t *testing.T, root, path string) string { - t.Helper() - b, err := os.ReadFile(filepath.Join(root, path)) - if err != nil { - t.Fatal(err) - } - return string(b) -} - -func TestConfigureDryRunIdempotentAndUserOverrides(t *testing.T) { - root := fixture(t) - ctx := context.Background() - binary := filepath.Join(t.TempDir(), "one with space", "one") - preview, err := Configure(ctx, root, binary, true) - if err != nil { - t.Fatal(err) - } - if !preview.DryRun || len(preview.GitChanges) != 2 || len(preview.Changes) != 3 { - t.Fatalf("preview: %+v", preview) - } - for _, path := range []string{"hk.pkl", workspace.HooksConfigFilename, workspace.MiseConfigFilename, ".gitignore", ".git/hooks/pre-commit"} { - if _, err := os.Stat(filepath.Join(root, path)); !os.IsNotExist(err) { - t.Fatalf("dry-run wrote %s", path) - } - } - if _, err := Configure(ctx, root, binary, false); err != nil { - t.Fatal(err) - } - if !strings.Contains(read(t, root, workspace.MiseConfigFilename), "aqua:jdx/hk") { - t.Fatal("hk not declared in mise") - } - custom := strings.Replace(read(t, root, workspace.HooksConfigFilename), "fix = false", "fix = true // Team override", 1) + "\n// Team checks\n" - write(t, root, workspace.HooksConfigFilename, custom) - again, err := Configure(ctx, root, binary, false) - if err != nil { - t.Fatal(err) - } - if len(again.Changes) != 0 || len(again.GitChanges) != 0 || read(t, root, workspace.HooksConfigFilename) != custom { - t.Fatalf("not idempotent: %+v", again) - } - if !strings.Contains(read(t, root, ".git/hooks/commit-msg"), shellQuote(filepath.ToSlash(binary))+" hk run commit-msg \"$@\"") { - t.Fatal("missing quoted fallback launcher") - } -} - -func TestConfigureConflictsDoNotPublish(t *testing.T) { - for _, file := range []string{"hk.pkl", ".git/hooks/pre-commit", ".git/hooks/commit-msg", "commitlint.config.js", "commitlint.config.ts", ".commitlintrc.json", ".husky/commit-msg", ".husky/pre-push"} { - t.Run(file, func(t *testing.T) { - root := fixture(t) - write(t, root, file, "user-owned content\n") - if _, err := Configure(context.Background(), root, "", false); err == nil { - t.Fatal("expected a conflict") - } - if read(t, root, file) != "user-owned content\n" { - t.Fatal("user file changed") - } - if _, err := os.Stat(filepath.Join(root, workspace.HooksConfigFilename)); !os.IsNotExist(err) { - t.Fatal("published a partial configuration") - } - }) - } - t.Run("package commitlint rules", func(t *testing.T) { - root := fixture(t) - write(t, root, "package.json", `{"commitlint":{"rules":{"header-max-length":[2,"always",50]}}}`) - if _, err := Configure(context.Background(), root, "", false); err == nil { - t.Fatal("expected conflict") - } - if _, err := os.Stat(filepath.Join(root, "hk.pkl")); !os.IsNotExist(err) { - t.Fatal("published a partial configuration") - } - }) - t.Run("custom hook directory", func(t *testing.T) { - root := fixture(t) - if _, err := gitOutput(context.Background(), root, "config", "--local", "core.hooksPath", ".custom-hooks"); err != nil { - t.Fatal(err) - } - if _, err := Configure(context.Background(), root, "", false); err == nil { - t.Fatal("expected conflict") - } - if value, _ := hooksPath(context.Background(), root); value != ".custom-hooks" { - t.Fatal("custom hooksPath changed") - } - }) -} - -func TestConfigureLinkedWorktreeUsesCommonGitHooks(t *testing.T) { - root := fixture(t) - ctx := context.Background() - for _, args := range [][]string{ - {"add", "one.manifest.toml"}, - {"-c", "user.name=One hook test", "-c", "user.email=test@example.invalid", "commit", "-qm", "test fixture"}, - } { - if out, err := gitOutput(ctx, root, args...); err != nil { - t.Fatalf("git %v: %v %s", args, err, out) - } - } - linked := filepath.Join(t.TempDir(), "linked") - if out, err := gitOutput(ctx, root, "worktree", "add", "--detach", linked); err != nil { - t.Fatalf("git worktree: %v %s", err, out) - } - result, err := Configure(ctx, linked, "", false) - if err != nil { - t.Fatal(err) - } - want, err := filepath.EvalSymlinks(filepath.Join(root, ".git")) - if err != nil || result.GitDirectory != want { - t.Fatalf("common Git directory: got %s, want %s, error %v", result.GitDirectory, want, err) - } - if !strings.Contains(read(t, root, ".git/hooks/pre-commit"), "hk run pre-commit") { - t.Fatal("common launcher missing") - } - if info, err := os.Stat(filepath.Join(linked, ".git")); err != nil || info.IsDir() { - t.Fatalf("linked worktree Git file replaced: %v", err) - } - if _, err := os.Stat(filepath.Join(linked, workspace.HooksConfigFilename)); err != nil { - t.Fatal("worktree hk configuration missing") - } -} - -func TestConfigureMigratesOnlyLegacyDefaults(t *testing.T) { - testLegacyMigration(t, fixture(t)) -} - -func TestConfigureMigratesLegacyDefaultsThroughSymlink(t *testing.T) { - root := fixture(t) - alias := filepath.Join(t.TempDir(), "workspace-alias") - if err := os.Symlink(root, alias); err != nil { - t.Skipf("directory symlinks unavailable: %v", err) - } - testLegacyMigration(t, alias) -} - -func testLegacyMigration(t *testing.T, root string) { - t.Helper() - for path, content := range legacyFiles { - write(t, root, path, content) - } - write(t, root, "package.json", `{"name":"keep","private":true,"packageManager":"pnpm@12.3.4","scripts":{"prepare":"husky","other":"keep-me"},"devDependencies":{"husky":"9.0.0","@commitlint/cli":"19.0.0","@commitlint/config-conventional":"19.0.0","@changesets/cli":"2.0.0"},"custom":{"keep":true}}`) - write(t, root, "pnpm-lock.yaml", "lockfileVersion: '9.0'\n") - if _, err := gitOutput(context.Background(), root, "config", "--local", "core.hooksPath", ".husky/_"); err != nil { - t.Fatal(err) - } - preview, err := Configure(context.Background(), root, "", true) - if err != nil { - t.Fatal(err) - } - if preview.HooksPath == "" || len(preview.Warnings) == 0 { - t.Fatalf("missing migration details: %+v", preview) - } - if _, err := Configure(context.Background(), root, "", false); err != nil { - t.Fatal(err) - } - for path := range legacyFiles { - if _, err := os.Stat(filepath.Join(root, path)); !os.IsNotExist(err) { - t.Fatalf("legacy file remains: %s", path) - } - } - var pkg map[string]any - if err := json.Unmarshal([]byte(read(t, root, "package.json")), &pkg); err != nil { - t.Fatal(err) - } - if pkg["name"] != "keep" || pkg["custom"] == nil || pkg["scripts"].(map[string]any)["other"] != "keep-me" { - t.Fatal("lost unrelated package settings") - } - deps := pkg["devDependencies"].(map[string]any) - if len(deps) != 1 || deps["@changesets/cli"] == nil { - t.Fatalf("deps: %+v", deps) - } - // Installation resolves the common Git directory, including Windows short - // paths and workspace symlinks. Compare the same canonical directory here. - wantPath, err := filepath.EvalSymlinks(filepath.Join(root, ".git", "hooks")) - if err != nil { - t.Fatal(err) - } - if value, err := hooksPath(context.Background(), root); err != nil || value != filepath.ToSlash(wantPath) { - t.Fatalf("hooksPath = %q, want %q, error %v", value, filepath.ToSlash(wantPath), err) - } - if read(t, root, "pnpm-lock.yaml") != "lockfileVersion: '9.0'\n" { - t.Fatal("rewrote package manager lockfile") - } -} - -func TestGeneratedLanguageStepsAndConcurrentEdit(t *testing.T) { - root := fixture(t) - write(t, root, "apps/web/package.json", `{"devDependencies":{"oxlint":"1.82.0","oxfmt":"0.67.0"}}`) - m := &workspace.Manifest{Projects: []workspace.ManifestProject{ - {Name: "api", RelativeDir: "services/api", Toolchain: "go"}, - {Name: "web", RelativeDir: "apps/web", Toolchain: "node"}, - }} - p := fsutil.NewFilePlan(root) - if err := PlanFiles(p, m); err != nil { - t.Fatal(err) - } - if err := p.Apply(context.Background()); err != nil { - t.Fatal(err) - } - config := read(t, root, workspace.HooksConfigFilename) - for _, part := range []string{`["api:format"]`, `dir = "services/api"`, `__hook-gofmt`, `["web:lint"]`, `Builtins.oxfmt`, `"pnpm", "exec"`, `fix = false`, `stage = false`, `stash = "git"`, `check-conventional-commit`} { - if !strings.Contains(config, part) { - t.Errorf("missing %s", part) - } - } - if strings.Contains(config, "tidy") { - t.Fatal("implicit go mod tidy") - } - p = fsutil.NewFilePlan(root) - if err := PlanFiles(p, m); err != nil { - t.Fatal(err) - } - write(t, root, workspace.HooksConfigFilename, config+"// concurrent edit\n") - if err := p.Apply(context.Background()); err == nil { - t.Fatal("expected concurrent edit conflict") - } - p = fsutil.NewFilePlan(root) - if err := PlanFiles(p, m); err != nil { - t.Fatal(err) - } - if len(p.Changes()) != 0 { - t.Fatal("comment-only edit caused a refresh") - } -} - -func TestRealHKValidatesGeneratedPkl(t *testing.T) { - binary := os.Getenv("ONE_TEST_HK_BINARY") - if binary == "" { - t.Skip("set ONE_TEST_HK_BINARY for native hk integration") - } - t.Setenv("PATH", filepath.Dir(binary)+string(os.PathListSeparator)+os.Getenv("PATH")) - root := fixture(t) - t.Setenv("XDG_CACHE_HOME", t.TempDir()) - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - write(t, root, "apps/web/package.json", `{"devDependencies":{"oxlint":"1.82.0","oxfmt":"0.67.0"}}`) - p := fsutil.NewFilePlan(root) - m := &workspace.Manifest{Projects: []workspace.ManifestProject{{Name: "api", RelativeDir: "services/api", Toolchain: "go"}, {Name: "web", RelativeDir: "apps/web", Toolchain: "node"}}} - if err := PlanFiles(p, m); err != nil { - t.Fatal(err) - } - if err := p.Apply(context.Background()); err != nil { - t.Fatal(err) - } - cmd := exec.Command(binary, "validate") - cmd.Dir = root - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("hk validate: %v %s", err, out) - } - for _, test := range []struct { - message string - valid bool - }{{"feat(api): 支持 Go 工作区\n", true}, {"fix!: change API\n\nBREAKING CHANGE: API changed\n", true}, {"fixup! temporary\n", true}, {"not a conventional commit\n", false}, {"unknown: test\n", false}} { - write(t, root, "commit message.txt", test.message) - cmd := exec.Command(binary, "run", "commit-msg", filepath.Join(root, "commit message.txt")) - cmd.Dir = root - out, err := cmd.CombinedOutput() - if (err == nil) != test.valid { - t.Fatalf("message %q: %v %s", test.message, err, out) - } - if !bytes.Equal([]byte(read(t, root, "commit message.txt")), []byte(test.message)) { - t.Fatal("commit message changed") - } - } -} diff --git a/packages/cli/internal/modules/hooks/native_unix_test.go b/packages/cli/internal/modules/hooks/native_unix_test.go deleted file mode 100644 index 361b9b8e..00000000 --- a/packages/cli/internal/modules/hooks/native_unix_test.go +++ /dev/null @@ -1,83 +0,0 @@ -//go:build !windows - -package hooks - -import ( - "context" - "os" - "os/exec" - "path/filepath" - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/fsutil" -) - -// Exercise hk itself: its file-list optimization resolves formatter output -// against the workspace root, even when a step runs inside a project directory. -func TestRealHKFixesProjectRelativeFilesWithoutStaging(t *testing.T) { - binary := os.Getenv("ONE_TEST_HK_BINARY") - if binary == "" { - t.Skip("set ONE_TEST_HK_BINARY for native hk integration") - } - root := fixture(t) - t.Setenv("XDG_CACHE_HOME", t.TempDir()) - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - t.Setenv("XDG_STATE_HOME", t.TempDir()) - bin := t.TempDir() - t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) - for name, script := range map[string]string{ - "mise": "shift; shift; exec \"$@\"\n", - "pnpm": "shift; exec \"$@\"\n", - "oxfmt": `mode=$1 -shift; shift -case "$mode" in - --check) [ "$(cat "$1")" = 'formatted' ] ;; - --list-different) printf '%s\n' "$1"; exit 1 ;; - --write) printf 'formatted\n' > "$1" ;; - *) exit 99 ;; -esac -`, - } { - path := filepath.Join(bin, name) - if err := os.WriteFile(path, []byte("#!/bin/sh\n"+script), 0o755); err != nil { - t.Fatal(err) - } - } - write(t, root, "apps/web/package.json", `{"devDependencies":{"oxfmt":"0.67.0"}}`) - file := "apps/web/src/file with spaces.ts" - write(t, root, file, "unformatted\n") - p := fsutil.NewFilePlan(root) - m := &workspace.Manifest{Projects: []workspace.ManifestProject{{Name: "web", RelativeDir: "apps/web", Toolchain: "node"}}} - if err := PlanFiles(p, m); err != nil { - t.Fatal(err) - } - if err := p.Apply(context.Background()); err != nil { - t.Fatal(err) - } - if _, err := gitOutput(context.Background(), root, "add", "--", file); err != nil { - t.Fatal(err) - } - indexBefore, err := gitOutput(context.Background(), root, "diff", "--cached", "--binary") - if err != nil { - t.Fatal(err) - } - for _, test := range []struct { - command string - valid bool - }{{"check", false}, {"fix", true}, {"check", true}} { - cmd := exec.Command(binary, test.command, file) - cmd.Dir = root - out, err := cmd.CombinedOutput() - if (err == nil) != test.valid { - t.Fatalf("hk %s: %v %s", test.command, err, out) - } - indexAfter, err := gitOutput(context.Background(), root, "diff", "--cached", "--binary") - if err != nil || indexAfter != indexBefore { - t.Fatalf("hk %s changed staged content: %v", test.command, err) - } - } - if got := read(t, root, file); got != "formatted\n" { - t.Fatalf("fix did not format the project file: %q", got) - } -} diff --git a/packages/cli/internal/modules/miseconfig/cache_test.go b/packages/cli/internal/modules/miseconfig/cache_test.go deleted file mode 100644 index ae1c7708..00000000 --- a/packages/cli/internal/modules/miseconfig/cache_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package miseconfig - -import ( - "context" - "os" - "path/filepath" - "reflect" - "testing" - - "github.com/pelletier/go-toml/v2" -) - -func TestGeneratedTasksDoNotEnableExperimentalCaching(t *testing.T) { - root := fixture(t) - plan, err := Build(root, Options{}) - if err != nil { - t.Fatal(err) - } - if err = plan.Apply(context.Background()); err != nil { - t.Fatal(err) - } - raw, err := os.ReadFile(filepath.Join(root, Filename)) - if err != nil { - t.Fatal(err) - } - var doc map[string]any - if err = toml.Unmarshal(raw, &doc); err != nil { - t.Fatal(err) - } - if _, ok := doc["settings"]; ok { - t.Fatal("generated mise settings", doc["settings"]) - } - for name, value := range doc["tasks"].(map[string]any) { - task := value.(map[string]any) - for _, field := range []string{"cache", "sources", "outputs"} { - if _, ok := task[field]; ok { - t.Fatalf("generated %s.%s", name, field) - } - } - } -} - -func TestGenerationPreservesExplicitUserCacheAndFreshness(t *testing.T) { - root := fixture(t) - userConfig := `[settings] -experimental = true -[tasks."web:build"] -dir = "apps/web" -run = "pnpm run build" -sources = ["src/**/*", "package.json"] -outputs = ["custom-dist"] -cache = { enabled = true, env = ["NODE_ENV"] } -` - writeFixture(t, filepath.Join(root, Filename), userConfig) - plan, err := Build(root, Options{}) - if err != nil { - t.Fatal(err) - } - if err = plan.Apply(context.Background()); err != nil { - t.Fatal(err) - } - raw, err := os.ReadFile(filepath.Join(root, Filename)) - if err != nil { - t.Fatal(err) - } - var before, after map[string]any - if err = toml.Unmarshal([]byte(userConfig), &before); err != nil { - t.Fatal(err) - } - if err = toml.Unmarshal(raw, &after); err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(before["settings"], after["settings"]) { - t.Fatal("changed user settings") - } - want := before["tasks"].(map[string]any)["web:build"].(map[string]any) - got := after["tasks"].(map[string]any)["web:build"].(map[string]any) - for key, value := range want { - if !reflect.DeepEqual(value, got[key]) { - t.Fatalf("changed user field %s: %#v", key, got[key]) - } - } -} diff --git a/packages/cli/internal/modules/miseconfig/config.go b/packages/cli/internal/modules/miseconfig/config.go index d290f75c..e09a5a43 100644 --- a/packages/cli/internal/modules/miseconfig/config.go +++ b/packages/cli/internal/modules/miseconfig/config.go @@ -296,7 +296,6 @@ func BuildWithFiles(root string, opts Options, files map[string][]byte) (*Plan, rootConfig.Tasks[project.Name+":"+name] = task } } - var ci Task for _, group := range m.Groups { operations := map[string][]string{} var builds []string @@ -326,15 +325,6 @@ func BuildWithFiles(root string, opts Options, files map[string][]byte) (*Plan, } } } - for _, name := range []string{"check", "test", "build"} { - if _, ok := rootConfig.Tasks[name]; ok { - ci.Depends = append(ci.Depends, name) - } - } - if len(ci.Depends) > 0 { - rootConfig.Tasks["ci"] = ci - } - if err := p.add(Filename, rootConfig); err != nil { return nil, err } diff --git a/packages/cli/internal/modules/miseconfig/config_test.go b/packages/cli/internal/modules/miseconfig/config_test.go deleted file mode 100644 index 362db871..00000000 --- a/packages/cli/internal/modules/miseconfig/config_test.go +++ /dev/null @@ -1,276 +0,0 @@ -package miseconfig - -import ( - "context" - "os" - "path/filepath" - "runtime" - "strings" - "testing" - - "github.com/pelletier/go-toml/v2" -) - -func fixture(t *testing.T) string { - t.Helper() - root := t.TempDir() - files := map[string]string{ - "one.manifest.toml": `version = 2 - -[workspace] -id = "mise-fixture" -name = "fixture" - -[projects."web"] -path = "apps/web" -toolchain = "node" - -[projects."api"] -path = "services/api" -toolchain = "go" -`, - "package.json": `{"packageManager":"pnpm@10.14.0"}`, - "apps/web/package.json": `{"scripts":{"dev":"vite","build":"vite build","test":"vitest run"}}`, - "services/api/go.mod": "module example.com/api\n\ngo 1.25.0\n", - "services/api/Taskfile.yml": "version: '3'\ntasks:\n build:\n cmds: ['go build ./...']\n test:\n cmds: ['go test ./...']\n", - "mise.toml": "# My existing configuration\n[env]\nKEEP = 'yes'\n", - } - for rel, raw := range files { - writeFixture(t, filepath.Join(root, rel), raw) - } - return root -} - -func writeFixture(t *testing.T, path, value string) { - t.Helper() - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte(value), 0o644); err != nil { - t.Fatal(err) - } -} - -func TestConfigurationGenerationIsAdditiveAndIdempotent(t *testing.T) { - root := fixture(t) - userBefore, _ := os.ReadFile(filepath.Join(root, "mise.toml")) - p, err := Build(root, Options{NodeVersion: "25.4.0"}) - if err != nil { - t.Fatal(err) - } - if len(p.Changes) != 2 || !p.DryRun { - t.Fatalf("unexpected plan: %+v", p) - } - if actual, _ := os.ReadFile(filepath.Join(root, Filename)); string(actual) != string(userBefore) { - t.Fatal("planning changed a file") - } - if err := p.Apply(context.Background()); err != nil { - t.Fatal(err) - } - rootRaw, _ := os.ReadFile(filepath.Join(root, Filename)) - var rootConfig config - if err := toml.Unmarshal(rootRaw, &rootConfig); err != nil { - t.Fatal(err) - } - if rootConfig.Tools["node"] != "25.4.0" || rootConfig.Tools["pnpm"] != "10.14.0" { - t.Fatalf("tools: %+v", rootConfig.Tools) - } - if !rootConfig.MonorepoRoot || len(rootConfig.Monorepo.ConfigRoots) != 0 { - t.Fatalf("expected root-only scheduling: %+v", rootConfig.Monorepo) - } - if rootConfig.Tasks["web:build"].Directory != "apps/web" || strings.Contains(string(rootRaw), "vite") { - t.Fatalf("tasks do not reference the source commands: %s", rootRaw) - } - if rootConfig.Tasks["web:dev"].Run != "pnpm run dev" { - t.Fatal("missing native command") - } - for _, dir := range []string{"apps/web", "services/api"} { - if _, err := os.Stat(filepath.Join(root, dir, Filename)); !os.IsNotExist(err) { - t.Fatalf("generated a project configuration in %s: %v", dir, err) - } - } - second, err := Build(root, Options{}) - if err != nil { - t.Fatal(err) - } - if len(second.Changes) != 0 { - t.Fatalf("not idempotent: %+v", second.Changes) - } - userAfter, _ := os.ReadFile(filepath.Join(root, "mise.toml")) - for _, line := range strings.Split(strings.TrimSpace(string(userBefore)), "\n") { - if !strings.Contains(string(userAfter), line) { - t.Fatalf("lost user text %q", line) - } - } -} - -func TestGoVersionHonorsMinimumAndToolchain(t *testing.T) { - root := fixture(t) - writeFixture(t, filepath.Join(root, "services/api/go.mod"), "module example.com/api\n\ngo 1.25\ntoolchain go1.25.6\n") - p, err := Build(root, Options{}) - if err != nil { - t.Fatal(err) - } - if err := p.Apply(context.Background()); err != nil { - t.Fatal(err) - } - raw, err := os.ReadFile(filepath.Join(root, Filename)) - if err != nil || !strings.Contains(string(raw), "1.25.6") { - t.Fatalf("toolchain ignored: %v %s", err, raw) - } - if _, err := Build(root, Options{GoVersion: "1.24.0"}); err == nil { - t.Fatal("accepted Go below go.mod minimum") - } -} - -func TestGoWorkspaceVersionIsPinnedAtRoot(t *testing.T) { - root := fixture(t) - writeFixture(t, filepath.Join(root, "go.work"), "go 1.27.0\nuse ./services/api\n") - p, err := Build(root, Options{}) - if err != nil { - t.Fatal(err) - } - if err := p.Apply(context.Background()); err != nil { - t.Fatal(err) - } - goConfig, _ := os.ReadFile(filepath.Join(root, Filename)) - if !strings.Contains(string(goConfig), "go = '1.27.0'") { - t.Fatalf("workspace version ignored: %s", goConfig) - } - if _, err := Build(root, Options{GoVersion: "1.26.0"}); err == nil { - t.Fatal("accepted override below workspace minimum") - } -} - -func TestManagedConfigurationConflictDoesNotOverwrite(t *testing.T) { - root := fixture(t) - p, err := Build(root, Options{}) - if err != nil { - t.Fatal(err) - } - if err := p.Apply(context.Background()); err != nil { - t.Fatal(err) - } - path := filepath.Join(root, Filename) - raw, _ := os.ReadFile(path) - modified := strings.Replace(string(raw), "node = '24.21.0'", "node = '25.0.0'", 1) + "\n# My edit\n" - writeFixture(t, path, modified) - if _, err := Build(root, Options{NodeVersion: "26.0.0"}); err == nil { - t.Fatal("expected same-entry conflict") - } - after, _ := os.ReadFile(path) - if string(after) != modified { - t.Fatal("conflict overwrote user's edit") - } -} - -func TestPlanRejectsChangedSourceBeforeWriting(t *testing.T) { - root := fixture(t) - p, err := Build(root, Options{}) - if err != nil { - t.Fatal(err) - } - writeFixture(t, filepath.Join(root, "apps/web/package.json"), `{"scripts":{"test":"changed"}}`) - if err := p.Apply(context.Background()); err == nil { - t.Fatal("expected stale-plan conflict") - } - if _, err := os.Stat(filepath.Join(root, "apps/web", Filename)); !os.IsNotExist(err) { - t.Fatal("stale plan wrote files") - } -} - -func TestPlanDistinguishesEmptySourceFromRemovedSource(t *testing.T) { - root := fixture(t) - path := filepath.Join(root, "services/api/Taskfile.yml") - writeFixture(t, path, "") - p, err := Build(root, Options{}) - if err != nil { - t.Fatal(err) - } - if err := os.Remove(path); err != nil { - t.Fatal(err) - } - if err := p.Apply(context.Background()); err == nil { - t.Fatal("source deletion was ignored") - } - if _, err := os.Stat(filepath.Join(root, "apps/web", Filename)); !os.IsNotExist(err) { - t.Fatal("stale plan wrote files") - } -} - -func TestGeneratorAcceptsEmptyFileAndRejectsSymlink(t *testing.T) { - t.Run("empty user file", func(t *testing.T) { - root := fixture(t) - writeFixture(t, filepath.Join(root, Filename), "") - if _, err := Build(root, Options{}); err != nil { - t.Fatal(err) - } - }) - t.Run("symlink", func(t *testing.T) { - root := fixture(t) - if err := os.Remove(filepath.Join(root, Filename)); err != nil { - t.Fatal(err) - } - if err := os.Symlink(filepath.Join(t.TempDir(), "mise.toml"), filepath.Join(root, Filename)); err != nil { - t.Skip(err) - } - if _, err := Build(root, Options{}); err == nil { - t.Fatal("symlink target must not be written") - } - }) -} - -func TestFailedWritePreservesRootConfiguration(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("permission failure fixture is Unix-specific") - } - root := fixture(t) - p, err := Build(root, Options{}) - if err != nil { - t.Fatal(err) - } - blocked := root - before, _ := os.ReadFile(filepath.Join(root, Filename)) - if err := os.MkdirAll(blocked, 0o755); err != nil { - t.Fatal(err) - } - if err := os.Chmod(blocked, 0o555); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = os.Chmod(blocked, 0o755) }) - if f, err := os.Create(filepath.Join(blocked, "probe")); err == nil { - f.Close() - t.Skip("process bypasses Unix directory permissions") - } - if err := p.Apply(context.Background()); err == nil { - t.Fatal("expected write failure") - } - after, _ := os.ReadFile(filepath.Join(root, Filename)) - if string(after) != string(before) { - t.Fatal("failed write changed root configuration") - } -} - -func TestGoWithoutTaskfileDoesNotGenerateBuildFallback(t *testing.T) { - root := fixture(t) - if err := os.Remove(filepath.Join(root, "services/api/Taskfile.yml")); err != nil { - t.Fatal(err) - } - plan, err := Build(root, Options{}) - if err != nil { - t.Fatal(err) - } - if err := plan.Apply(context.Background()); err != nil { - t.Fatal(err) - } - raw, err := os.ReadFile(filepath.Join(root, Filename)) - if err != nil { - t.Fatal(err) - } - if strings.Contains(string(raw), "[tasks.'api:build']") { - t.Fatal("Go build fallback must not be generated without a Taskfile") - } - if strings.Contains(string(raw), "[tasks.'api:test']") { - t.Fatal("test task must come from Taskfile") - } -} diff --git a/packages/cli/internal/modules/miseconfig/dependencies_test.go b/packages/cli/internal/modules/miseconfig/dependencies_test.go deleted file mode 100644 index 38e86d74..00000000 --- a/packages/cli/internal/modules/miseconfig/dependencies_test.go +++ /dev/null @@ -1,21 +0,0 @@ -package miseconfig - -import ( - "strings" - "testing" -) - -func TestSourceDependencyClosureIncludesLibrariesWithoutBuild(t *testing.T) { - edges := map[string][]string{"web": {"middle"}, "middle": {"base"}} - deps, err := dependencyClosure("web", edges) - if err != nil { - t.Fatal(err) - } - if strings.Join(deps, ",") != "base,middle" { - t.Fatal(deps) - } - edges["base"] = []string{"web"} - if _, err = dependencyClosure("web", edges); err == nil { - t.Fatal("source dependency cycle accepted") - } -} diff --git a/packages/cli/internal/modules/miseconfig/trust_test.go b/packages/cli/internal/modules/miseconfig/trust_test.go deleted file mode 100644 index 37a91c4a..00000000 --- a/packages/cli/internal/modules/miseconfig/trust_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package miseconfig - -import ( - "context" - "errors" - "os" - "path/filepath" - "reflect" - "strings" - "testing" - - runtimeport "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/runtime" -) - -type trustProbe struct { - commands []runtimeport.Command - prepare func(runtimeport.Command) (runtimeport.Command, error) -} - -func (p *trustProbe) Prepare(context.Context, runtimeport.Command) (runtimeport.Command, error) { - panic("trust must not load the project environment") -} -func (p *trustProbe) PrepareCLI(_ context.Context, command runtimeport.Command) (runtimeport.Command, error) { - p.commands = append(p.commands, command) - return p.prepare(command) -} - -func TestTrustOnlyCompleteGeneratedConfigurations(t *testing.T) { - root := fixture(t) // The root has a user-defined [env] block. - plan, err := Build(root, Options{}) - if err != nil { - t.Fatal(err) - } - if err := plan.Apply(context.Background()); err != nil { - t.Fatal(err) - } - unavailable := errors.New("runtime unavailable") - probe := &trustProbe{prepare: func(c runtimeport.Command) (runtimeport.Command, error) { return c, unavailable }} - if err := plan.TrustGenerated(context.Background(), probe); err != nil || len(probe.commands) != 0 { - t.Fatalf("custom root must not be auto-trusted: %v %v", err, probe.commands) - } - writeFixture(t, filepath.Join(root, Filename), "") - plan, err = Build(root, Options{}) - if err != nil { - t.Fatal(err) - } - if err := plan.Apply(context.Background()); err != nil { - t.Fatal(err) - } - if err := plan.TrustGenerated(context.Background(), probe); !errors.Is(err, unavailable) { - t.Fatalf("lost preparation error: %v", err) - } - var paths []string - for _, command := range probe.commands { - if command.Directory != plan.Root || !reflect.DeepEqual(command.Argv[:3], []string{"trust", "--yes", "--quiet"}) { - t.Fatal(command) - } - paths = append(paths, command.Argv[3]) - } - if !reflect.DeepEqual(paths, []string{filepath.Join(plan.Root, Filename)}) { - t.Fatalf("trusted unexpected configs: %v", paths) - } -} - -func TestTrustRejectsFileChangedWhilePreparingRuntime(t *testing.T) { - root := fixture(t) - writeFixture(t, filepath.Join(root, Filename), "") - plan, err := Build(root, Options{}) - if err != nil { - t.Fatal(err) - } - if err := plan.Apply(context.Background()); err != nil { - t.Fatal(err) - } - generated := plan.generated[0] - path := filepath.Join(plan.Root, generated.Path) - probe := &trustProbe{prepare: func(c runtimeport.Command) (runtimeport.Command, error) { - writeFixture(t, path, generated.After+"\n[env]\nUSER_CHANGE = 'yes'\n") - c.Argv = []string{"must-not-execute"} - return c, nil - }} - err = plan.trustFile(context.Background(), probe, generated) - if err == nil || strings.Contains(err.Error(), "must-not-execute") { - t.Fatalf("changed file reached execution: %v", err) - } - raw, _ := os.ReadFile(path) - if !strings.Contains(string(raw), "USER_CHANGE") { - t.Fatal("user change was overwritten") - } -} diff --git a/packages/cli/internal/modules/skills/catalog.go b/packages/cli/internal/modules/skills/catalog.go index 00dba527..421e4fed 100644 --- a/packages/cli/internal/modules/skills/catalog.go +++ b/packages/cli/internal/modules/skills/catalog.go @@ -67,11 +67,10 @@ func Defaults(templateID, repo string) []Selection { "expo-overview", "expo-router", "expo-data-fetching", "expo-dev-client", "expo-upgrade", "expo-project-structure", }}) case "ts-library": - node.Names = append(node.Names, "vitest") plan = append(plan, node, Selection{Source: tsSource, Names: []string{"tsdown"}}) case "electron-app": plan[0].Names = append(plan[0].Names, "one-electron", "one-web") - node.Names = append(node.Names, "vite", "vitest") + node.Names = append(node.Names, "vite") plan = append(plan, node, Selection{Source: tsSource, Names: []string{"tsdown"}}) plan = append(plan, web...) } diff --git a/packages/cli/internal/modules/skills/catalog_test.go b/packages/cli/internal/modules/skills/catalog_test.go deleted file mode 100644 index d5e018e6..00000000 --- a/packages/cli/internal/modules/skills/catalog_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package skills - -import ( - "reflect" - "testing" -) - -func TestTemplateAdaptationSkillsStayScoped(t *testing.T) { - for _, tt := range []struct { - template string - repo string - want []string - }{ - {"nestjs-api", "local:nestjs-api", []string{"one-cli", "one-nestjs"}}, - {"go-api", "local:go-api", []string{"one-cli", "one-go"}}, - {"go-lib", "local:go-lib", []string{"one-cli", "one-go"}}, - {"expo-mobile", "local:expo-mobile", []string{"one-cli", "one-expo"}}, - {"react-spa", "local:react-spa", []string{"one-cli", "one-web"}}, - {"nextjs-app", "local:nextjs-app", []string{"one-cli", "one-web"}}, - {"nextjs-site", "local:nextjs-site", []string{"one-cli", "one-web"}}, - {"fumadocs-docs", "local:fumadocs-docs", []string{"one-cli", "one-web", "one-fumadocs"}}, - {"electron-app", "local:electron-app", []string{"one-cli", "one-electron", "one-web"}}, - {"ts-library", "local:ts-library", []string{"one-cli"}}, - {"empty-app", "local:empty-app", []string{"one-cli"}}, - {"empty-service", "local:empty-service", []string{"one-cli"}}, - {"empty-library", "local:empty-library", []string{"one-cli"}}, - {"nextjs-app", "https://example.com/custom-next", []string{"one-cli"}}, - {"custom", "local:custom", []string{"one-cli"}}, - {"", "", []string{"one-cli"}}, - } { - t.Run(tt.template+"/"+tt.repo, func(t *testing.T) { - var names []string - for _, selection := range Defaults(tt.template, tt.repo) { - if selection.Source == "" { - names = append(names, selection.Names...) - } - } - if !reflect.DeepEqual(names, tt.want) { - t.Fatalf("template adaptation skills = %v, want %v", names, tt.want) - } - }) - } -} diff --git a/packages/cli/internal/modules/skills/install_test.go b/packages/cli/internal/modules/skills/install_test.go deleted file mode 100644 index 2703a984..00000000 --- a/packages/cli/internal/modules/skills/install_test.go +++ /dev/null @@ -1,370 +0,0 @@ -package skills - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "io" - "os" - "path/filepath" - "reflect" - "strings" - "testing" - "time" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/template" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" -) - -func writeSkill(t *testing.T, root, name, content string) { - t.Helper() - dir := filepath.Join(root, ".agents", "skills", name) - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644); err != nil { - t.Fatal(err) - } -} - -func fakeInstall(t *testing.T, calls *[][]string) RunFunc { - return func(_ context.Context, root string, args []string, _ io.Reader, _, _ io.Writer) error { - *calls = append(*calls, append([]string(nil), args...)) - lock, err := readLock(root) - if err != nil { - t.Fatal(err) - } - for index := 3; index < len(args) && args[index] != "--agent"; index++ { - name := args[index] - if _, err := os.Stat(filepath.Join(root, ".agents", "skills", name, "SKILL.md")); os.IsNotExist(err) { - writeSkill(t, root, name, "original") - } - entry := lockEntry{Source: sourceRepo(args[1]), SourceType: "github"} - if strings.HasPrefix(args[1], "./") { - entry.Source, entry.SourceType = args[1], "local" - } - lock[name] = entry - } - raw, _ := json.Marshal(map[string]any{"version": 1, "skills": lock}) - return os.WriteFile(filepath.Join(root, "skills-lock.json"), raw, 0o644) - } -} - -func TestInstallIsIdempotentAndPreservesUserChanges(t *testing.T) { - root := t.TempDir() - var calls [][]string - installer := Installer{Run: fakeInstall(t, &calls)} - plan := Defaults("electron-app", "local:electron-app") - if warnings := installer.Install(context.Background(), root, plan); len(warnings) != 0 { - t.Fatal(warnings) - } - firstCalls := len(calls) - if firstCalls != len(plan) { - t.Fatalf("calls=%d want %d", firstCalls, len(plan)) - } - writeSkill(t, root, "one-cli", "team modifications") - lockBefore, _ := os.ReadFile(filepath.Join(root, "skills-lock.json")) - if warnings := installer.Install(context.Background(), root, plan); len(warnings) != 0 || len(calls) != firstCalls { - t.Fatalf("repeat changed installations: %v %v", warnings, calls) - } - got, _ := os.ReadFile(filepath.Join(root, ".agents", "skills", "one-cli", "SKILL.md")) - lockAfter, _ := os.ReadFile(filepath.Join(root, "skills-lock.json")) - if string(got) != "team modifications" || string(lockAfter) != string(lockBefore) { - t.Fatal("repeat overwrote user content or lock") - } - for _, call := range calls { - if !reflect.DeepEqual(call[len(call)-4:], []string{"--agent", "universal", "--copy", "--yes"}) { - t.Fatalf("unexpected flags %q", call) - } - } -} - -func TestBundledRegistrationRetriesAfterFailure(t *testing.T) { - for _, partial := range []bool{false, true} { - t.Run(map[bool]string{false: "unregistered", true: "partially registered"}[partial], func(t *testing.T) { - root := t.TempDir() - var calls, registrations [][]string - register := fakeInstall(t, ®istrations) - installer := Installer{Run: func(ctx context.Context, directory string, args []string, in io.Reader, out, stderr io.Writer) error { - calls = append(calls, append([]string(nil), args...)) - if len(calls) == 1 { - if partial { - first := append(append([]string(nil), args[:4]...), args[len(args)-4:]...) - if err := register(ctx, directory, first, in, out, stderr); err != nil { - t.Fatal(err) - } - } - return errors.New("registration interrupted") - } - return register(ctx, directory, args, in, out, stderr) - }} - plan := []Selection{{Names: []string{"one-cli", "one-electron"}}} - if warnings := installer.Install(context.Background(), root, plan); len(warnings) != 1 { - t.Fatalf("failure was not reported: %v", warnings) - } - want := plan[0].Names - if partial { - writeSkill(t, root, "one-cli", "team modifications") - want = []string{"one-electron"} - } - if warnings := installer.Install(context.Background(), root, plan); len(warnings) != 0 || len(calls) != 2 { - t.Fatalf("registration did not retry: warnings=%v calls=%v", warnings, calls) - } - if got := calls[1][3 : len(calls[1])-4]; !reflect.DeepEqual(got, want) { - t.Fatalf("retried skills=%v want %v", got, want) - } - lock, err := readLock(root) - if err != nil || len(lock) != 2 { - t.Fatalf("retry did not finish registration: %v %v", lock, err) - } - if partial { - content, err := os.ReadFile(filepath.Join(root, ".agents", "skills", "one-cli", "SKILL.md")) - if err != nil || string(content) != "team modifications" { - t.Fatalf("retry changed an installed skill: %q %v", content, err) - } - } - if warnings := installer.Install(context.Background(), root, plan); len(warnings) != 0 || len(calls) != 2 { - t.Fatalf("successful retry was not idempotent: %v %v", warnings, calls) - } - }) - } -} - -func TestUnregisteredTeamSkillsArePreserved(t *testing.T) { - for _, file := range []string{"SKILL.md", "references/layouts.md"} { - t.Run(file, func(t *testing.T) { - root := t.TempDir() - plan := []Selection{{Names: []string{"one-cli"}}} - failed := Installer{Run: func(context.Context, string, []string, io.Reader, io.Writer, io.Writer) error { - return errors.New("registration interrupted") - }} - if warnings := failed.Install(context.Background(), root, plan); len(warnings) != 1 { - t.Fatal(warnings) - } - path := filepath.Join(root, ".agents", "skills", "one-cli", filepath.FromSlash(file)) - if err := os.WriteFile(path, []byte("team modifications"), 0o644); err != nil { - t.Fatal(err) - } - var calls [][]string - warnings := (Installer{Run: fakeInstall(t, &calls)}).Install(context.Background(), root, plan) - content, err := os.ReadFile(path) - if len(warnings) != 0 || len(calls) != 0 || err != nil || string(content) != "team modifications" { - t.Fatalf("unregistered team skill changed: %v %v %q %v", warnings, calls, content, err) - } - if _, err := os.Stat(filepath.Join(root, "skills-lock.json")); !os.IsNotExist(err) { - t.Fatalf("team skill was registered implicitly: %v", err) - } - }) - } -} - -func TestSourceConflictAndInvalidLockArePreserved(t *testing.T) { - for _, raw := range []string{`{"version":1,"skills":{"shadcn":{"source":"team/custom","sourceType":"github"}}}`, `{"version":99,"skills":{}}`, `invalid json`} { - root := t.TempDir() - lockPath := filepath.Join(root, "skills-lock.json") - _ = os.WriteFile(lockPath, []byte(raw), 0o644) - var calls [][]string - warnings := (Installer{Run: fakeInstall(t, &calls)}).Install(context.Background(), root, []Selection{{Source: uiSource, Names: []string{"shadcn"}}}) - got, _ := os.ReadFile(lockPath) - if len(warnings) != 1 || len(calls) != 0 || string(got) != raw { - t.Fatalf("conflict mutated state: %v %q %q", warnings, calls, got) - } - } -} - -func TestFailureAndTimeoutReturnRecoveryAndKeepWorkspace(t *testing.T) { - for _, timedOut := range []bool{false, true} { - root := t.TempDir() - marker := filepath.Join(root, "keep.txt") - _ = os.WriteFile(marker, []byte("project files"), 0o644) - installer := Installer{Timeout: 30 * time.Millisecond, Run: func(ctx context.Context, _ string, _ []string, _ io.Reader, _, _ io.Writer) error { - if timedOut { - <-ctx.Done() - return ctx.Err() - } - return errors.New("network unavailable") - }} - warnings := installer.Install(context.Background(), root, []Selection{{Source: goSource, Names: []string{"golang-patterns"}}}) - got, _ := os.ReadFile(marker) - if len(warnings) != 1 || !strings.Contains(warnings[0], "one skills add "+goSource) || string(got) != "project files" { - t.Fatalf("recovery=%v marker=%q", warnings, got) - } - } -} - -func TestAutomaticInstallHidesToolUIAndRetainsFailureReason(t *testing.T) { - t.Cleanup(func() { _ = i18n.Init(i18n.DefaultLocale) }) - for _, locale := range []string{"en-US", "zh-CN"} { - t.Run(locale, func(t *testing.T) { - if err := i18n.Init(locale); err != nil { - t.Fatal(err) - } - for _, fail := range []bool{false, true} { - root := t.TempDir() - var log bytes.Buffer - var calls [][]string - install := fakeInstall(t, &calls) - installer := Installer{Log: &log, Run: func(ctx context.Context, directory string, args []string, in io.Reader, out, stderr io.Writer) error { - if _, err := io.WriteString(out, strings.Repeat("SKILLS UPSTREAM BANNER\n", 300)); err != nil { - return err - } - if _, err := io.WriteString(stderr, "\x1b[31mfatal: permission denied /tmp/skill-source\x1b[0m\n"); err != nil { - return err - } - if fail { - return errors.New("installation failed") - } - return install(ctx, directory, args, in, out, stderr) - }} - warnings := installer.Install(context.Background(), root, []Selection{{Names: []string{"one-cli"}}}) - if got, want := log.String(), i18n.Tf("skills.installing", "one-cli")+"\n"; got != want { - t.Fatalf("automatic install leaked upstream UI: %q, want %q", got, want) - } - if !fail && len(warnings) != 0 { - t.Fatal(warnings) - } - if fail && (len(warnings) != 1 || !strings.Contains(warnings[0], "fatal: permission denied /tmp/skill-source") || !strings.Contains(warnings[0], "one skills add ") || strings.Contains(warnings[0], "\x1b")) { - t.Fatalf("failure lost its diagnosis or recovery command: %v", warnings) - } - } - }) - } -} - -func TestDevelopmentSkillsKeepOnlySelectedPortableCopies(t *testing.T) { - for _, names := range [][]string{ - {"one-cli"}, - {"one-cli", "one-nestjs"}, - {"one-cli", "one-go"}, - {"one-cli", "one-expo"}, - {"one-cli", "one-web"}, - {"one-cli", "one-electron", "one-web"}, - {"one-cli", "one-web", "one-fumadocs"}, - } { - t.Run(strings.Join(names, "+"), func(t *testing.T) { - root := t.TempDir() - var calls [][]string - warnings := (Installer{Run: fakeInstall(t, &calls)}).Install(context.Background(), root, []Selection{{Names: names}}) - if len(warnings) != 0 || len(calls) != 1 || calls[0][1] != "./.agents/skills" { - t.Fatalf("local registration failed: %v %v", warnings, calls) - } - entries, err := os.ReadDir(filepath.Join(root, ".agents", "skills")) - if err != nil || len(entries) != len(names) { - t.Fatalf("unselected skills were written: %v %v", entries, err) - } - lock, err := readLock(root) - if err != nil { - t.Fatal(err) - } - for _, name := range names { - entry := lock[name] - if entry.SourceType != "local" || entry.Source != "./.agents/skills" { - t.Fatalf("nonportable source for %s: %+v", name, entry) - } - if raw, err := os.ReadFile(filepath.Join(root, entry.Source, name, "SKILL.md")); err != nil || !strings.Contains(string(raw), "name: "+name) { - t.Fatalf("source is not the installed skill: %s %v", name, err) - } - } - if _, err := os.Stat(filepath.Join(root, ".one")); !os.IsNotExist(err) { - t.Fatalf("duplicate sources remain: %v", err) - } - if _, err := os.Stat(filepath.Join(root, ".agents", "skills", "one-cli", "references", "layouts.md")); err != nil { - t.Fatal("selected skill lost its references", err) - } - }) - } -} - -func TestLegacyBundledSourcesPreserveTeamFilesAndLock(t *testing.T) { - for _, source := range []string{"./.one/skill-sources/0123456789abcdef", "./packages/agent-skills", "./.agents/skills"} { - t.Run(source, func(t *testing.T) { - root := t.TempDir() - writeSkill(t, root, "one-cli", "team modifications") - raw, err := json.Marshal(map[string]any{"version": 1, "skills": map[string]any{"one-cli": map[string]any{"source": source, "sourceType": "local", "computedHash": "team-hash"}}}) - if err != nil { - t.Fatal(err) - } - lockPath := filepath.Join(root, "skills-lock.json") - if err := os.WriteFile(lockPath, raw, 0o644); err != nil { - t.Fatal(err) - } - var calls [][]string - installer := Installer{Run: fakeInstall(t, &calls)} - warnings := installer.Install(context.Background(), root, []Selection{{Names: []string{"one-cli"}}}) - after, err := os.ReadFile(lockPath) - if len(warnings) != 0 || len(calls) != 0 || err != nil || !bytes.Equal(after, raw) { - t.Fatalf("existing installation changed: %v %v %s %v", warnings, calls, after, err) - } - warnings = installer.Install(context.Background(), root, []Selection{{Names: []string{"one-cli", "one-electron"}}}) - content, err := os.ReadFile(filepath.Join(root, ".agents", "skills", "one-cli", "SKILL.md")) - if len(warnings) != 0 || len(calls) != 1 || !reflect.DeepEqual(calls[0][3:4], []string{"one-electron"}) || err != nil || string(content) != "team modifications" { - t.Fatalf("adding a skill overwrote team changes: %v %v %q %v", warnings, calls, content, err) - } - }) - } -} - -func TestReleaseSkillsUsePublishedCommitAndPreserveOlderSources(t *testing.T) { - previous := bundledSourceRef - bundledSourceRef = strings.Repeat("a", 40) - t.Cleanup(func() { bundledSourceRef = previous }) - root := t.TempDir() - var calls [][]string - installer := Installer{Run: fakeInstall(t, &calls)} - plan := []Selection{{Names: []string{"one-cli", "one-electron"}}} - warnings := installer.Install(context.Background(), root, plan) - want := oneSource + "/packages/agent-skills#" + bundledSourceRef - if len(warnings) != 0 || len(calls) != 1 || calls[0][1] != want { - t.Fatalf("release did not use its published commit: %v %v", warnings, calls) - } - lock, err := readLock(root) - if err != nil || lock["one-cli"].Source != oneSource || lock["one-cli"].SourceType != "github" { - t.Fatalf("unexpected GitHub provenance: %v %v", lock, err) - } - writeSkill(t, root, "one-cli", "team changes") - warnings = installer.Install(context.Background(), root, plan) - if len(warnings) != 0 || len(calls) != 1 { - t.Fatalf("release reinstalled existing files: %v %v", warnings, calls) - } - // A release also accepts a development/legacy local source without forcing - // a provenance migration or overwriting the installed instructions. - legacyRoot := t.TempDir() - writeSkill(t, legacyRoot, "one-cli", "legacy team changes") - raw := []byte(`{"version":1,"skills":{"one-cli":{"source":"./.one/skill-sources/old","sourceType":"local","computedHash":"team"}}}`) - lockPath := filepath.Join(legacyRoot, "skills-lock.json") - if err := os.WriteFile(lockPath, raw, 0o644); err != nil { - t.Fatal(err) - } - warnings = installer.Install(context.Background(), legacyRoot, []Selection{{Names: []string{"one-cli"}}}) - after, err := os.ReadFile(lockPath) - if len(warnings) != 0 || len(calls) != 1 || err != nil || !bytes.Equal(raw, after) { - t.Fatalf("release migrated the legacy lock implicitly: %v %v %v", warnings, calls, err) - } -} - -func TestDefaultsCoverRegistryWithoutBusinessSkills(t *testing.T) { - registry, err := template.Fetch(context.Background(), "") - if err != nil { - t.Fatal(err) - } - for _, entry := range registry.Templates { - plan := Defaults(entry.ID, entry.Repo) - if len(plan) < 2 || plan[0].Names[0] != "one-cli" || plan[1].Source != findSource || !reflect.DeepEqual(plan[1].Names, []string{"find-skills"}) { - t.Fatalf("missing common skills: %s %v", entry.ID, plan) - } - if entry.Toolchain != "none" && len(plan) <= 2 { - t.Fatalf("unmapped template: %s", entry.ID) - } - for _, selection := range plan { - for _, name := range selection.Names { - if strings.Contains(name, "auth") || strings.Contains(name, "business") { - t.Fatalf("unexpected automatic skill: %s", name) - } - } - } - } - if got := Defaults("react-spa", "https://example.com/custom"); len(got) != 2 { - t.Fatalf("custom template inherited built-in assumptions: %v", got) - } -} diff --git a/packages/cli/internal/modules/skills/runner_test.go b/packages/cli/internal/modules/skills/runner_test.go deleted file mode 100644 index d4ab1538..00000000 --- a/packages/cli/internal/modules/skills/runner_test.go +++ /dev/null @@ -1,95 +0,0 @@ -package skills - -import ( - "bytes" - "context" - "encoding/json" - "io" - "os" - "path/filepath" - "reflect" - "runtime" - "testing" - - platformprocess "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/process" - runtimeport "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/runtime" -) - -type helperRuntime struct{ prepared runtimeport.Command } - -func (r *helperRuntime) Prepare(ctx context.Context, command runtimeport.Command) (runtimeport.Command, error) { - return r.PrepareCLI(ctx, command) -} - -func (r *helperRuntime) PrepareCLI(_ context.Context, command runtimeport.Command) (runtimeport.Command, error) { - r.prepared = command - command.Argv = append([]string{os.Args[0], "-test.run=^TestSkillsHelperProcess$", "--"}, command.Argv...) - return command, nil -} - -type helperResult struct { - Directory string - Args []string - Input string -} - -func TestSkillsHelperProcess(t *testing.T) { - if os.Getenv("ONE_TEST_SKILLS_CHILD") != "1" { - return - } - index := 0 - for index < len(os.Args) && os.Args[index] != "--" { - index++ - } - dir, _ := os.Getwd() - in, _ := io.ReadAll(os.Stdin) - _ = json.NewEncoder(os.Stdout).Encode(helperResult{Directory: dir, Args: os.Args[index+1:], Input: string(in)}) - _, _ = io.WriteString(os.Stderr, "upstream stderr\n") - os.Exit(37) -} - -func TestRunnerPreparesMissingNodeWithoutChangingArguments(t *testing.T) { - for _, linked := range []bool{false, true} { - t.Run(map[bool]string{false: "directory", true: "directory alias"}[linked], func(t *testing.T) { - t.Setenv("PATH", "") - t.Setenv("ONE_TEST_SKILLS_CHILD", "1") - provider := &helperRuntime{} - root := t.TempDir() - if linked { - alias := filepath.Join(t.TempDir(), "workspace") - if err := os.Symlink(root, alias); err != nil { - if runtime.GOOS == "windows" { - t.Skipf("creating a directory symlink requires Windows privileges: %v", err) - } - t.Fatal(err) - } - root = alias - } - args := []string{"add", "owner/repo", "--skill", "a b", "", "$(literal)", "--global", "--yes"} - var out, stderr bytes.Buffer - err := (Runner{Runtime: provider}).Run(context.Background(), root, args, bytes.NewBufferString("input\n"), &out, &stderr) - if platformprocess.ExitCode(err) != 37 || stderr.String() != "upstream stderr\n" { - t.Fatalf("exit=%v stderr=%q", err, stderr.String()) - } - var got helperResult - if err := json.Unmarshal(out.Bytes(), &got); err != nil { - t.Fatal(err) - } - parentDirectory, err := os.Stat(root) - if err != nil { - t.Fatal(err) - } - childDirectory, err := os.Stat(got.Directory) - if err != nil { - t.Fatal(err) - } - if !os.SameFile(parentDirectory, childDirectory) { - t.Fatalf("child directory=%q want %q", got.Directory, root) - } - want := append([]string{"exec", "node@24.21.0", "--", "npx", "--yes", "skills@" + CLIVersion}, args...) - if provider.prepared.Directory != root || got.Input != "input\n" || !reflect.DeepEqual(got.Args, want) || !reflect.DeepEqual(provider.prepared.Argv, want) { - t.Fatalf("prepared directory=%q args=%q child=%+v", provider.prepared.Directory, provider.prepared.Argv, got) - } - }) - } -} diff --git a/packages/cli/internal/modules/tasks/broker_ui_test.go b/packages/cli/internal/modules/tasks/broker_ui_test.go deleted file mode 100644 index ea78e866..00000000 --- a/packages/cli/internal/modules/tasks/broker_ui_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package tasks - -import ( - "encoding/json" - "net/http/httptest" - "strings" - "sync" - "testing" -) - -func TestBrokerDisplaySnapshotKeepsStartAcrossSteps(t *testing.T) { - b := &invocationBroker{token: "test", specs: map[string]leafSpec{"task-0": {Task: Task{Name: "//:silent"}, Environment: []string{"SECRET=value"}}}, events: map[string]leafEvent{}} - report := func(status string) { - req := httptest.NewRequest("POST", "/task-0", strings.NewReader(`{"status":"`+status+`"}`)) - req.Header.Set("Authorization", "Bearer test") - response := httptest.NewRecorder() - b.ServeHTTP(response, req) - if response.Code != 204 { - t.Error(response.Code) - } - } - report("running") - first := b.snapshot()[0] - report("running") - next := b.snapshot()[0] - if first.StartedAt.IsZero() || first.StartedAt != next.StartedAt { - t.Fatal("multi-step or duplicate start reset ordering") - } - snapshot := b.snapshot() - snapshot[0].Status = "failed" - if b.snapshot()[0].Status != "running" { - t.Fatal("snapshot mutated broker") - } - var wg sync.WaitGroup - wg.Go(func() { - for range 100 { - report("running") - } - }) - wg.Go(func() { - for range 100 { - _ = b.snapshot() - } - }) - wg.Wait() - report("succeeded") - last := b.snapshot()[0] - if last.EndedAt.IsZero() || last.StartedAt != first.StartedAt { - t.Fatal("completion lost lifecycle time") - } - raw, _ := json.Marshal(last) - if strings.Contains(string(raw), "SECRET") || strings.Contains(string(raw), "value") { - t.Fatal("UI state exposed environment") - } -} diff --git a/packages/cli/internal/modules/tasks/catalog_view_test.go b/packages/cli/internal/modules/tasks/catalog_view_test.go deleted file mode 100644 index a4958866..00000000 --- a/packages/cli/internal/modules/tasks/catalog_view_test.go +++ /dev/null @@ -1,97 +0,0 @@ -package tasks - -import ( - "bytes" - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/charmbracelet/x/ansi" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" -) - -func catalogExample() []Task { - return []Task{ - {Name: "//:web:dev", Project: "web", Description: "Start the web app", Source: "mise.toml", Interactive: true}, - {Name: "//:build", Project: "web", Description: "Build workspace applications", Source: "mise.toml"}, - {Name: "//:install", Source: "mise.local.toml"}, - {Name: "//apps/api:test", Project: "api", Description: "Run API tests", Source: "apps/api/mise.toml", Cached: true}, - {Name: "//tools:check", Description: "Check tooling", Source: "tools/mise.toml"}, - } -} -func TestCatalogOutputSnapshots(t *testing.T) { - old := i18n.Active() - t.Cleanup(func() { i18n.Init(old) }) - for _, locale := range []string{"en-US", "zh-CN"} { - t.Run(locale, func(t *testing.T) { - i18n.Init(locale) - var out bytes.Buffer - catalogLayout{width: 90}.render(&out, catalogExample()) - path := filepath.Join("testdata", "catalog-"+locale+".txt") - if os.Getenv("UPDATE_SNAPSHOTS") == "1" { - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, out.Bytes(), 0644); err != nil { - t.Fatal(err) - } - } - want, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if out.String() != string(want) { - t.Fatalf("catalog output changed:\n%s", out.String()) - } - }) - } -} -func TestCatalogDetailsColorsAndNarrowWidths(t *testing.T) { - old := i18n.Active() - t.Cleanup(func() { i18n.Init(old) }) - tasks := catalogExample() - tasks = append(tasks, Task{Name: "//:" + strings.Repeat("编译🧑‍💻", 12), Description: strings.Repeat("中文描述 long-word-without-breaks ", 4), Source: strings.Repeat("long/", 12) + "mise.toml"}) - before, _ := json.Marshal(MarshalCatalog(tasks)) - for _, locale := range []string{"en-US", "zh-CN"} { - i18n.Init(locale) - for _, width := range []int{8, 20, 45, 64, 80, 140} { - for _, verbose := range []bool{false, true} { - for _, color := range []bool{false, true} { - var out bytes.Buffer - catalogLayout{width: width, color: color, verbose: verbose}.render(&out, tasks) - got := out.String() - if strings.Contains(got, "\x1b[") != color { - t.Fatalf("unexpected style color=%v: %q", color, got) - } - for _, line := range strings.Split(got, "\n") { - if ansi.StringWidth(line) > width { - t.Fatalf("width=%d line=%q", width, line) - } - } - compact := strings.Join(strings.Fields(ansi.Strip(got)), "") - if strings.Contains(compact, "mise.toml") != verbose { - t.Fatalf("verbose=%v did not control metadata", verbose) - } - if !strings.Contains(compact, strings.Repeat("编译🧑‍💻", 12)) { - t.Fatal("long task name was lost") - } - } - } - } - } - after, _ := json.Marshal(MarshalCatalog(tasks)) - if !bytes.Equal(before, after) { - t.Fatal("rendering changed the catalog protocol or task order") - } -} -func TestCatalogProjectGroupsFollowNamespaces(t *testing.T) { - groups := groupCatalog(catalogExample()) - if len(groups) != 4 || groups[0].project != "" || groups[0].scope != "" || len(groups[0].tasks) != 2 || groups[0].tasks[0].Name != "//:build" { - t.Fatal("root entry grouped by its working directory", groups) - } - if groups[1].project != "api" || groups[2].project != "web" || groups[3].scope != "//tools" { - t.Fatal("project or native scope group missing", groups) - } -} diff --git a/packages/cli/internal/modules/tasks/compile_test.go b/packages/cli/internal/modules/tasks/compile_test.go deleted file mode 100644 index bf7b0af6..00000000 --- a/packages/cli/internal/modules/tasks/compile_test.go +++ /dev/null @@ -1,118 +0,0 @@ -package tasks - -import ( - "context" - "encoding/json" - "os" - "path/filepath" - "runtime" - "strings" - "testing" - "time" -) - -func TestWaitsAreSelectedOnlyAndRejectCombinedCycles(t *testing.T) { - w := taskWorkspace(t) - writeTaskFile(t, w.Root(), "mise.toml", ` - [tasks.root] - depends=["a","b"] - [tasks.a] - wait_for=["b","unselected"] - [tasks.b] - wait_for=["a"] - [tasks.unselected] - run="{{ dynamic }}" - `) - if _, err := NewPlan(w, Options{Name: "root"}); err == nil { - t.Fatal("combined wait cycle accepted") - } - plan, err := NewPlan(w, Options{Name: "a"}) - if err != nil || len(plan.Tasks) != 1 || len(plan.Tasks[0].waitFor) != 0 { - t.Fatalf("%+v %v", plan, err) - } -} - -func TestTaskEnvironmentIsPrivateAndUnsetOverrides(t *testing.T) { - task := Task{} - validateTaskFields(&task, map[string]any{"env": map[string]any{"VALUE": "synthetic-secret", "REMOVE": false}}) - if task.unsupported != "" || task.env["VALUE"] != "synthetic-secret" || len(task.unsetEnv) != 1 { - t.Fatal(task) - } - raw, _ := json.Marshal(task) - if strings.Contains(string(raw), "synthetic-secret") { - t.Fatal("task plan exposes environment") - } - validateTaskFields(&task, map[string]any{"env": map[string]any{"REMOVE": "restored"}}) - if len(task.unsetEnv) != 0 { - t.Fatal("unset persisted after overriding value") - } -} - -func TestFreshnessHandlesRecursiveGlobsAndMissingOutputs(t *testing.T) { - dir := t.TempDir() - input := filepath.Join(dir, "src", "nested", "source.go") - if err := os.MkdirAll(filepath.Dir(input), 0700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(input, []byte("input"), 0600); err != nil { - t.Fatal(err) - } - past := time.Now().Add(-time.Hour) - if err := os.Chtimes(input, past, past); err != nil { - t.Fatal(err) - } - output := filepath.Join(dir, "result") - if err := os.WriteFile(output, []byte("output"), 0600); err != nil { - t.Fatal(err) - } - task := Task{Directory: dir, Sources: []string{"src/**/*.go"}, Outputs: []string{"result"}} - if !fresh(task) { - t.Fatal("recursive input failed freshness") - } - future := time.Now().Add(time.Hour) - if err := os.Chtimes(input, future, future); err != nil { - t.Fatal(err) - } - if fresh(task) { - t.Fatal("changed source reused output") - } - task.Sources = []string{"missing"} - if fresh(task) { - t.Fatal("missing source reused output") - } - task.Sources = []string{"src/**/*.go"} - task.Outputs = append(task.Outputs, "missing") - if fresh(task) { - t.Fatal("missing output reused output") - } -} - -func TestFileTaskAliasesHiddenAndUnsupportedMetadata(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("POSIX executable scripts") - } - w := taskWorkspace(t) - writeTaskFile(t, w.Root(), "mise-tasks/internal", "#!/bin/sh\n#MISE alias = 'shortcut'\n#MISE hide = true\nprintf ready\n") - writeTaskFile(t, w.Root(), "mise-tasks/dynamic", "#!/bin/sh\n#MISE env = 42\nprintf ignored\n") - for _, name := range []string{"internal", "dynamic"} { - if err := os.Chmod(filepath.Join(w.Root(), "mise-tasks", name), 0755); err != nil { - t.Fatal(err) - } - } - plan, err := NewPlan(w, Options{Name: "shortcut"}) - if err != nil || len(plan.Tasks) != 1 || plan.Entries[0] != "//:internal" { - t.Fatalf("%+v %v", plan, err) - } - catalog, err := (Service{}).Catalog(context.Background(), w) - if err != nil { - t.Fatal(err) - } - for _, task := range catalog { - if task.Name == "//:internal" || task.Name == "//:shortcut" { - t.Fatal("hidden task listed", task.Name) - } - } - if _, err := NewPlan(w, Options{Name: "dynamic"}); err == nil { - t.Fatal("invalid selected metadata accepted") - } -} diff --git a/packages/cli/internal/modules/tasks/options_test.go b/packages/cli/internal/modules/tasks/options_test.go deleted file mode 100644 index 3a51fbb8..00000000 --- a/packages/cli/internal/modules/tasks/options_test.go +++ /dev/null @@ -1,21 +0,0 @@ -package tasks - -import "testing" - -func TestConcurrencyIsIndependentOfTaskName(t *testing.T) { - for _, name := range []string{"dev", "serve", "build"} { - p := &Plan{Tasks: []Task{{Name: "web", Run: "pnpm run serve"}, {Name: "api", Run: "task serve"}}} - opts := Options{Name: name, Jobs: 1} - if err := executionOptions(p, &opts); err != nil || opts.Jobs != 2 { - t.Fatalf("%+v %v", opts, err) - } - opts = Options{Name: name, Jobs: 1, JobsExplicit: true} - if err := executionOptions(p, &opts); err == nil { - t.Fatal(opts, err) - } - p.Tasks[0].Interactive = true - if err := executionOptions(p, &opts); err == nil { - t.Fatal("concurrent interactive task accepted") - } - } -} diff --git a/packages/cli/internal/modules/tasks/plan_test.go b/packages/cli/internal/modules/tasks/plan_test.go deleted file mode 100644 index bdec718b..00000000 --- a/packages/cli/internal/modules/tasks/plan_test.go +++ /dev/null @@ -1,154 +0,0 @@ -package tasks - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/application/execution" -) - -func writeTaskFile(t *testing.T, root, path, content string) { - t.Helper() - path = filepath.Join(root, path) - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte(content), 0644); err != nil { - t.Fatal(err) - } -} -func taskWorkspace(t *testing.T) execution.Workspace { - t.Helper() - root := t.TempDir() - writeTaskFile(t, root, "one.manifest.toml", `version = 2 - -[workspace] -id = "test" -name = "test" - -[projects."web"] -path = "apps/web" -toolchain = "none" - -[projects."lib"] -path = "packages/lib" -toolchain = "none" -`) - writeTaskFile(t, root, "apps/web/package.json", `{"scripts":{"dev":"echo web","start":"echo start","build":"echo build"}}`) - writeTaskFile(t, root, "packages/lib/package.json", `{"scripts":{"build":"echo lib"}}`) - writeTaskFile(t, root, "mise.toml", `[tasks.build] -depends=["web:build","lib:build"] -[tasks."web:build"] -dir="apps/web" -run="echo web" -depends=["lib:build"] -[tasks."lib:build"] -dir="packages/lib" -run="echo lib" -`) - w, err := execution.ResolveWorkspaceScope(execution.NewScope(context.Background(), root)) - if err != nil { - t.Fatal(err) - } - return w -} -func TestPlanUsesExistingTasksWithoutWrites(t *testing.T) { - w := taskWorkspace(t) - before, _ := os.ReadFile(filepath.Join(w.Root(), "mise.toml")) - p, err := NewPlan(w, Options{Name: "build", Projects: []string{"web", "lib"}}) - if err != nil { - t.Fatal(err) - } - if len(p.Tasks) != 2 || p.Tasks[0].Project != "lib" || p.Tasks[1].Project != "web" || len(p.ConfigChanges) != 0 { - t.Fatalf("%+v", p) - } - for _, opts := range []Options{{Name: "dev"}, {Name: "start", Projects: []string{"web"}}, {Name: "build", Projects: []string{"web", "lib"}, Arguments: []string{"flag"}}, {Name: "build", Environment: "missing"}} { - if _, err := NewPlan(w, opts); err == nil { - t.Fatalf("unexpected plan: %+v", opts) - } - } - after, _ := os.ReadFile(filepath.Join(w.Root(), "mise.toml")) - if string(before) != string(after) { - t.Fatal("planning changed configuration") - } -} -func TestPlanProjectOwnershipFollowsDirectory(t *testing.T) { - w := taskWorkspace(t) - writeTaskFile(t, w.Root(), "mise.toml", `[tasks.serve-backend] -dir="apps/web" -run="echo real-task"`) - p, err := NewPlan(w, Options{Name: "serve-backend"}) - if err != nil { - t.Fatal(err) - } - if p.Tasks[0].Project != "web" { - t.Fatal(p.Tasks) - } - if _, err = NewPlan(w, Options{Name: "dev", Projects: []string{"web"}}); err == nil { - t.Fatal("invented dev task") - } -} -func TestPlanRejectsCyclesAndOverlappingOutputs(t *testing.T) { - for _, config := range []string{`[tasks.build] -depends=["build"]`, `[tasks.build] -depends=["a","b"] -[tasks.a] -run="echo a" -outputs=["dist"] -[tasks.b] -run="echo b" -outputs=["dist/child"]`, `[tasks.build] -run="echo x" -outputs=["../outside"]`} { - w := taskWorkspace(t) - writeTaskFile(t, w.Root(), "mise.toml", config) - if _, err := NewPlan(w, Options{Name: "build"}); err == nil { - t.Fatal("invalid plan accepted") - } - } -} -func TestPlanDoesNotEvaluateCommandInputs(t *testing.T) { - w := taskWorkspace(t) - writeTaskFile(t, w.Root(), "mise.local.toml", `[tasks."web:build".cache] -enabled=true -command_inputs=["echo forbidden > SHOULD_NOT_EXIST"]`) - _, err := NewPlan(w, Options{Name: "build"}) - if err == nil || !strings.Contains(err.Error(), "cache.command_inputs") { - t.Fatal("unsupported command input accepted", err) - } - if _, err = os.Stat(filepath.Join(w.Root(), "SHOULD_NOT_EXIST")); !os.IsNotExist(err) { - t.Fatal("dry-run executed input") - } -} - -func TestStaticNativeScopesAliasesAndCommandOverrides(t *testing.T) { - w := taskWorkspace(t) - writeTaskFile(t, w.Root(), "mise.toml", `monorepo_root=true -[monorepo] -config_roots=["apps/*"] -[tasks.build] -depends=["//apps/web:compile"] -`) - writeTaskFile(t, w.Root(), "apps/web/mise.toml", `[tasks.build] -alias="compile" -run="echo base" -depends=["missing-old-dependency"] -`) - writeTaskFile(t, w.Root(), "apps/web/mise.local.toml", `[tasks.build] -alias="compile" -run="echo override" -`) - plan, err := NewPlan(w, Options{Name: "build"}) - if err != nil { - t.Fatal(err) - } - if len(plan.Tasks) != 2 || plan.Tasks[0].Run != "echo override" || plan.Tasks[0].Project != "web" { - t.Fatal(plan.Tasks) - } - if _, err = NewPlan(w, Options{Name: "build", Projects: []string{"web"}}); err != nil { - t.Fatal(err) - } -} diff --git a/packages/cli/internal/modules/tasks/secrets_test.go b/packages/cli/internal/modules/tasks/secrets_test.go deleted file mode 100644 index f9e16ec7..00000000 --- a/packages/cli/internal/modules/tasks/secrets_test.go +++ /dev/null @@ -1,487 +0,0 @@ -package tasks - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "net/http" - "os" - "os/exec" - "path/filepath" - "reflect" - "runtime" - "strings" - "sync" - "testing" - "time" - - "github.com/pelletier/go-toml/v2" - "github.com/torchstellar-team/one-cli/packages/cli/internal/application/execution" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" - "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/dependencies" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" - process "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/process" - runtimeport "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/runtime" - "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/secrets" -) - -type fixtureLoader struct { - mu sync.Mutex - calls map[string]int - values map[string]map[string]string -} - -func (l *fixtureLoader) ID() string { return "infisical" } -func (l *fixtureLoader) Load(_ context.Context, _ string, project, _ string) (map[string]string, error) { - l.mu.Lock() - defer l.mu.Unlock() - l.calls[project]++ - return l.values[project], nil -} - -type nativeProvider struct{ binary string } - -func (p nativeProvider) PrepareCLI(_ context.Context, c runtimeport.Command) (runtimeport.Command, error) { - c.Argv = append([]string{p.binary}, c.Argv...) - return c, nil -} -func (p nativeProvider) Prepare(ctx context.Context, c runtimeport.Command) (runtimeport.Command, error) { - c.Argv = append([]string{"exec", "--"}, c.Argv...) - return p.PrepareCLI(ctx, c) -} -func syntheticLoader() *fixtureLoader { - return &fixtureLoader{calls: map[string]int{}, values: map[string]map[string]string{ - "apps/web": {"ONE_TEST_SHARED": "fake-web", "ONE_TEST_WEB": "web-only", "ONE_TEST_COMPLEX": "中文 ' \" $() `x` \\ \n\r\n", "ONE_TEST_EMPTY": ""}, - "packages/lib": {"ONE_TEST_SHARED": "fake-lib", "ONE_TEST_LIB": "lib-only", "ONE_TEST_COMPLEX": "中文 ' \" $() `x` \\ \n\r\n", "ONE_TEST_EMPTY": ""}, - }} -} -func hashValues(v map[string]string) string { - raw, _ := json.Marshal(v) - sum := sha256.Sum256(raw) - return hex.EncodeToString(sum[:]) -} -func TestNativeEnvironmentChild(t *testing.T) { - if os.Getenv("ONE_NATIVE_CHILD") != "1" { - return - } - if os.Getenv("ONE_NATIVE_ROOT") == "1" { - fmt.Println("ROOT_RAN") - return - } - values := map[string]string{} - for _, entry := range os.Environ() { - k, v, _ := strings.Cut(entry, "=") - if strings.HasPrefix(k, "ONE_TEST_") && k != "ONE_TEST_MISE_BINARY" { - values[k] = v - } - } - fmt.Println("ENV_HASH=" + hashValues(values)) - if os.Getenv("ONE_NATIVE_GRANDCHILD") != "1" { - c := exec.Command(os.Args[0], "-test.run=^TestNativeEnvironmentChild$") - c.Env = append(os.Environ(), "ONE_NATIVE_GRANDCHILD=1") - c.Stdout = os.Stdout - c.Stderr = os.Stderr - if err := c.Run(); err != nil { - os.Exit(17) - } - } - // The requested raw-log contract: child values are not filtered by One. - fmt.Println("CHILD_LOG=" + values["ONE_TEST_SHARED"]) - time.Sleep(40 * time.Millisecond) -} -func TestNativeMiseProjectEnvironmentsAndCache(t *testing.T) { - for _, name := range []string{"direct_workspace", "symlinked_workspace"} { - t.Run(name, func(t *testing.T) { - testNativeMiseProjectEnvironmentsAndCache(t, name == "symlinked_workspace") - }) - } -} - -func testNativeMiseProjectEnvironmentsAndCache(t *testing.T, symlinked bool) { - t.Helper() - binary, err := exec.LookPath("mise") - if err != nil { - t.Skip("mise is not installed") - } - if value := os.Getenv("ONE_TEST_MISE_BINARY"); value != "" { - binary = value - } - compose, err := exec.Command(binary, "which", "process-compose", "--tool", "process-compose@1.122.0").Output() - if err != nil { - t.Skipf("official pinned Process Compose is required: %v", err) - } - t.Setenv("PATH", filepath.Dir(strings.TrimSpace(string(compose)))+string(os.PathListSeparator)+os.Getenv("PATH")) - w := taskWorkspace(t) - if symlinked { - // Reproduce macOS /var -> /private/var path aliases on other platforms. - link := filepath.Join(t.TempDir(), "workspace") - if err := os.Symlink(w.Root(), link); err != nil { - if runtime.GOOS == "windows" { - t.Skipf("symlink creation unavailable: %v", err) - } - t.Fatal(err) - } - w, err = execution.ResolveWorkspaceScope(execution.NewScope(context.Background(), link)) - if err != nil { - t.Fatal(err) - } - if w.Root() != link { - t.Fatalf("workspace did not retain symlink path: %s", w.Root()) - } - } - w.Manifest().Env = &workspace.EnvironmentConfig{ProjectID: "synthetic", Environments: []string{"dev", "staging", "prod"}} - isolated := t.TempDir() - for _, key := range []string{"MISE_DATA_DIR", "MISE_STATE_DIR", "MISE_CACHE_DIR", "MISE_CONFIG_DIR", "MISE_SYSTEM_CONFIG_DIR"} { - t.Setenv(key, filepath.Join(isolated, key)) - } - t.Setenv("MISE_GLOBAL_CONFIG_FILE", filepath.Join(isolated, "global.toml")) - t.Setenv("MISE_TRUSTED_CONFIG_PATHS", w.Root()) - t.Setenv("MISE_AUTO_INSTALL", "0") - t.Setenv("MISE_EXPERIMENTAL", "0") - t.Setenv("MISE_TASK_RUN_AUTO_INSTALL", "0") - t.Setenv("MISE_TASK_CACHE_DIR", filepath.Join(isolated, "task-cache")) - t.Setenv("ONE_NATIVE_CHILD", "1") - loader := syntheticLoader() - quote := func(s string) string { - if runtime.GOOS == "windows" { - return `"` + s + `"` - } - return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" - } - // Exercise task shell quoting independently of the Go build cache path, - // which may use Windows short names without any spaces. - executable, err := os.Executable() - if err != nil { - t.Fatal(err) - } - data, err := os.ReadFile(executable) - if err != nil { - t.Fatal(err) - } - childDir := filepath.Join(t.TempDir(), "tools with spaces") - if err := os.MkdirAll(childDir, 0o755); err != nil { - t.Fatal(err) - } - child := filepath.Join(childDir, filepath.Base(executable)) - if err := os.WriteFile(child, data, 0o755); err != nil { - t.Fatal(err) - } - command := quote(child) + " -test.run=^TestNativeEnvironmentChild$" - config := map[string]any{"tasks": map[string]any{"build": map[string]any{"depends": []string{"web:build", "lib:build"}, "run": command, "env": map[string]string{"ONE_NATIVE_ROOT": "1"}, "sources": []string{"apps/web/package.json"}, "outputs": []string{"root-dist"}, "cache": map[string]any{"enabled": true}}, "web:build": map[string]any{"dir": "apps/web", "run": command, "sources": []string{"package.json"}, "outputs": []string{"dist"}, "cache": map[string]any{"enabled": true}}, "lib:build": map[string]any{"dir": "packages/lib", "run": command}}} - raw, _ := toml.Marshal(config) - writeTaskFile(t, w.Root(), "mise.toml", string(raw)) - writeTaskFile(t, w.Root(), "apps/web/dist/existing", "old output") - writeTaskFile(t, w.Root(), "root-dist/existing", "old output") - // A local command override must retain both its command and the injected env. - local, _ := toml.Marshal(map[string]any{"tasks": map[string]any{"web:build": map[string]any{"dir": "apps/web", "run": command, "sources": []string{"package.json"}, "outputs": []string{"dist"}, "cache": map[string]any{"enabled": true}}}}) - writeTaskFile(t, w.Root(), "mise.local.toml", string(local)) - var selectedLoader secrets.Loader = loader - if symlinked { - selectedLoader = &fixtureBatchLoader{fixtureLoader: loader} - } - service := Service{Provider: nativeProvider{binary}, Loaders: secrets.MustRegistry(selectedLoader), Prepare: func(context.Context, dependencies.Input) error { return nil }, WorkerCommand: testWorkerCommand} - opts := Options{Name: "build", UI: "stream", Cache: "off", Jobs: 2} - for round := 0; round < 3; round++ { - if round == 1 { - loader.values["apps/web"]["ONE_TEST_SHARED"] = "rotated" - } - if round == 2 { - delete(loader.values["apps/web"], "ONE_TEST_EMPTY") - } - plan, err := service.Plan(context.Background(), w, opts) - if err != nil { - t.Fatal(err) - } - var out, errOut bytes.Buffer - result, err := service.Execute(context.Background(), w, plan, opts, nil, &out, &errOut) - if err != nil { - t.Fatalf("%v\n%s\n%s", err, out.String(), errOut.String()) - } - if result.Status != "succeeded" { - t.Fatal(result) - } - log := out.String() + errOut.String() - if !strings.Contains(log, "ROOT_RAN\n") { - t.Fatalf("downstream freshness skipped: %s", log) - } - for _, values := range loader.values { - if strings.Count(log, "ENV_HASH="+hashValues(values)) != 2 { - t.Fatalf("environment or grandchild mismatch: %s", log) - } - if !strings.Contains(log, "CHILD_LOG="+values["ONE_TEST_SHARED"]) { - t.Fatal("raw child log was changed") - } - } - if loader.calls["apps/web"] != round+1 || loader.calls["packages/lib"] != round+1 { - t.Fatal(loader.calls) - } - } - after, _ := os.ReadFile(filepath.Join(w.Root(), "mise.toml")) - if !bytes.Equal(raw, after) { - t.Fatal("run changed mise config") - } - - t.Run("simultaneous invocations keep independent snapshots", func(t *testing.T) { - var wg sync.WaitGroup - failures := make(chan error, 2) - for _, label := range []string{"first", "second"} { - wg.Go(func() { - isolatedLoader := syntheticLoader() - for _, values := range isolatedLoader.values { - values["ONE_TEST_SHARED"] = label - } - independent := service - independent.Loaders = secrets.MustRegistry(isolatedLoader) - plan, err := independent.Plan(context.Background(), w, opts) - if err != nil { - failures <- err - return - } - var out, stderr bytes.Buffer - _, err = independent.Execute(context.Background(), w, plan, opts, nil, &out, &stderr) - if err != nil { - failures <- fmt.Errorf("%w: %s %s", err, out.String(), stderr.String()) - return - } - for _, values := range isolatedLoader.values { - if strings.Count(out.String()+stderr.String(), "ENV_HASH="+hashValues(values)) != 2 { - failures <- fmt.Errorf("cross-invocation values: %s %s", out.String(), stderr.String()) - return - } - } - }) - } - wg.Wait() - close(failures) - for err := range failures { - t.Error(err) - } - }) - t.Run("native task alias retains environment", func(t *testing.T) { - local, _ := toml.Marshal(map[string]any{"tasks": map[string]any{"web:build": map[string]any{"alias": "compile", "dir": "apps/web", "run": quote(os.Args[0]) + " -test.run=^TestNativeEnvironmentChild$"}}}) - writeTaskFile(t, w.Root(), "mise.local.toml", string(local)) - aliasOpts := opts - aliasOpts.Name = "compile" - plan, err := service.Plan(context.Background(), w, aliasOpts) - if err != nil { - t.Fatal(err) - } - var out, stderr bytes.Buffer - _, err = service.Execute(context.Background(), w, plan, aliasOpts, nil, &out, &stderr) - if err != nil { - t.Fatalf("%v: %s %s", err, out.String(), stderr.String()) - } - if strings.Count(out.String()+stderr.String(), "ENV_HASH="+hashValues(loader.values["apps/web"])) != 2 { - t.Fatalf("alias lost environment: %s %s", out.String(), stderr.String()) - } - }) - t.Run("profile replacement retains the project snapshot", func(t *testing.T) { - t.Setenv("MISE_ENV", "one-test") - writeTaskFile(t, w.Root(), "mise.one-test.toml", string(local)) - t.Cleanup(func() { os.Remove(filepath.Join(w.Root(), "mise.one-test.toml")) }) - plan, err := service.Plan(context.Background(), w, opts) - if err != nil { - t.Fatal(err) - } - var out, stderr bytes.Buffer - _, err = service.Execute(context.Background(), w, plan, opts, nil, &out, &stderr) - if err != nil || !strings.Contains(out.String()+stderr.String(), "ENV_HASH="+hashValues(loader.values["apps/web"])) { - t.Fatalf("profile lost environment: %v %s %s", err, out.String(), stderr.String()) - } - }) - t.Run("native project scopes with identical task names", func(t *testing.T) { - if err := os.Remove(filepath.Join(w.Root(), "mise.local.toml")); err != nil { - t.Fatal(err) - } - writeTaskFile(t, w.Root(), "mise.toml", `monorepo_root=true -[monorepo] -config_roots=["apps/web","packages/lib"] -[tasks.build] -depends=["//apps/web:build","//packages/lib:build"] -`) - for _, dir := range []string{"apps/web", "packages/lib"} { - raw, _ := toml.Marshal(map[string]any{"tasks": map[string]any{"build": map[string]any{"run": quote(os.Args[0]) + " -test.run=^TestNativeEnvironmentChild$"}}}) - writeTaskFile(t, w.Root(), dir+"/mise.toml", string(raw)) - } - plan, err := service.Plan(context.Background(), w, opts) - if err != nil { - t.Fatal(err) - } - var out, errOut bytes.Buffer - _, err = service.Execute(context.Background(), w, plan, opts, nil, &out, &errOut) - if err != nil { - t.Fatalf("%v: %s %s", err, out.String(), errOut.String()) - } - for _, values := range loader.values { - if strings.Count(out.String()+errOut.String(), "ENV_HASH="+hashValues(values)) != 2 { - t.Fatalf("scoped environment mismatch: %s %s", out.String(), errOut.String()) - } - } - // Project selection resolves the native scope, without inventing a root alias. - selected := opts - selected.Projects = []string{"web"} - if plan, err = service.Plan(context.Background(), w, selected); err != nil || len(plan.Tasks) != 1 { - t.Fatalf("%+v %v", plan, err) - } - }) - t.Run("file task keeps its native source and project environment", func(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("POSIX executable file task") - } - if err := os.Remove(filepath.Join(w.Root(), "apps/web/mise.toml")); err != nil { - t.Fatal(err) - } - writeTaskFile(t, w.Root(), "apps/web/mise.toml", "[tasks.build]\ndescription='native file'\n") - path := filepath.Join(w.Root(), "apps/web/.mise/tasks/build") - writeTaskFile(t, w.Root(), "apps/web/.mise/tasks/build", "#!/bin/sh\nexec "+quote(os.Args[0])+" -test.run=^TestNativeEnvironmentChild$\n") - if err := os.Chmod(path, 0755); err != nil { - t.Fatal(err) - } - selected := opts - selected.Projects = []string{"web"} - plan, err := service.Plan(context.Background(), w, selected) - if err != nil { - t.Fatal(err) - } - var out, stderr bytes.Buffer - _, err = service.Execute(context.Background(), w, plan, selected, nil, &out, &stderr) - if err != nil { - t.Fatalf("%v: %s %s", err, out.String(), stderr.String()) - } - if strings.Count(out.String()+stderr.String(), "ENV_HASH="+hashValues(loader.values["apps/web"])) != 2 { - t.Fatalf("file task lost environment: %s %s", out.String(), stderr.String()) - } - }) - -} -func TestEnvironmentSessionContainsNoValuesOnDiskAndCleansUp(t *testing.T) { - w := taskWorkspace(t) - w.Manifest().Env = &workspace.EnvironmentConfig{ProjectID: "synthetic", Environments: []string{"dev", "staging", "prod"}} - plan, err := NewPlan(w, Options{Name: "build"}) - if err != nil { - t.Fatal(err) - } - loader := syntheticLoader() - service := Service{Loaders: secrets.MustRegistry(loader)} - base := secrets.MergeIntoEnviron(os.Environ(), map[string]string{"MISE_PLUGINS_DIR": t.TempDir()}, true) - session, err := service.prepareEnvironment(context.Background(), w, plan, base) - if err != nil { - t.Fatal(err) - } - defer session.close() - config, err := composeConfig(plan, testWorkerCommand) - if err != nil { - t.Fatal(err) - } - for _, values := range loader.values { - for _, value := range values { - if value != "" && bytes.Contains(config, []byte(value)) { - t.Fatal("values written into config") - } - } - } - broker, err := newInvocationBroker(map[string]leafSpec{"task-0": {Task: plan.Tasks[0], Environment: session.env}}) - if err != nil { - t.Fatal(err) - } - defer broker.close() - endpoint := broker.endpoint + "/task-0" - r, err := http.Get(endpoint) - if err != nil { - t.Fatal(err) - } - r.Body.Close() - if r.StatusCode != http.StatusUnauthorized { - t.Fatal("unauthenticated request accepted") - } - broker.close() - client := http.Client{Timeout: time.Second} - if r, err := client.Get(endpoint); err == nil { - r.Body.Close() - t.Fatal("broker remained open") - } - -} - -// Load deliberately fails so a regression to single-project loading is visible. -type fixtureBatchLoader struct { - *fixtureLoader - batches int - err error - omit bool -} - -func (l *fixtureBatchLoader) Load(context.Context, string, string, string) (map[string]string, error) { - return nil, errors.New("single-project load called") -} -func (l *fixtureBatchLoader) LoadProjects(ctx context.Context, root string, dirs []string, env string) (map[string]map[string]string, error) { - l.batches++ - if l.err != nil { - return nil, l.err - } - values := map[string]map[string]string{} - for _, dir := range dirs { - if _, exists := values[dir]; exists { - return nil, errors.New("duplicate batch directory") - } - v, err := l.fixtureLoader.Load(ctx, root, dir, env) - if err != nil { - return nil, err - } - if !l.omit { - values[dir] = v - } - } - return values, nil -} - -func TestEnvironmentBatchFailureLeavesNoSession(t *testing.T) { - for _, locale := range []string{"en-US", "zh-CN"} { - t.Run(locale, func(t *testing.T) { - if err := i18n.Init(locale); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = i18n.Init("en-US") }) - for _, omit := range []bool{false, true} { - w := taskWorkspace(t) - w.Manifest().Env = &workspace.EnvironmentConfig{ProjectID: "fixture"} - l := &fixtureBatchLoader{fixtureLoader: syntheticLoader(), omit: omit} - if !omit { - l.err = errors.New("synthetic permission denied") - } - service := Service{Loaders: secrets.MustRegistry(l)} - before, _ := os.ReadDir(w.Root()) - session, err := service.prepareEnvironment(context.Background(), w, &Plan{Environment: "dev", Tasks: []Task{{Project: "web"}, {Project: "lib"}, {Project: "web"}}}, nil) - if session != nil || err == nil || (!omit && !errors.Is(err, l.err)) { - t.Fatalf("unexpected result: %v %v", session, err) - } - if omit && !strings.Contains(err.Error(), "web") && !strings.Contains(err.Error(), "lib") { - t.Fatal("missing project context", err) - } - after, _ := os.ReadDir(w.Root()) - if !reflect.DeepEqual(before, after) || l.batches != 1 { - t.Fatal("failed load created session files or repeated batch") - } - } - }) - } -} - -func testWorkerCommand(id string) []string { - return []string{os.Args[0], "-test.run=^TestProcessLeafChild$", "--", id} -} -func TestProcessLeafChild(t *testing.T) { - if os.Getenv("ONE_PROCESS_ENDPOINT") == "" { - return - } - err := RunProcessLeaf(context.Background(), os.Args[len(os.Args)-1], os.Stdin, os.Stdout, os.Stderr) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(process.ExitCode(err)) - } - os.Exit(0) -} diff --git a/packages/cli/internal/modules/tasks/taskui_test.go b/packages/cli/internal/modules/tasks/taskui_test.go deleted file mode 100644 index ce2c9c45..00000000 --- a/packages/cli/internal/modules/tasks/taskui_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package tasks - -import ( - "reflect" - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/taskui" -) - -func TestTaskGraphResolvesScopesGlobsAndAliases(t *testing.T) { - p := &Plan{Entries: []string{"//:start"}, Tasks: []Task{ - {Name: "//:prepare", nativeName: "prepare"}, - {Name: "//apps/api:prepare", nativeName: "//apps/api:prepare"}, - {Name: "//apps/api:dev", nativeName: "//apps/api:dev", Dependencies: []string{"prepare", "//:prepare"}}, - {Name: "//:web:dev", nativeName: "web:dev", Dependencies: []string{"prepare"}}, - {Name: "//:start", nativeName: "dev", Dependencies: []string{"//apps/api:dev", "web:*", "web:dev"}}, - {Name: "//:dev", nativeName: "dev", Dependencies: []string{"//apps/api:dev", "web:*", "web:dev"}}, - }} - want := taskui.Graph{Entries: []string{"//:dev"}, Tasks: []taskui.Task{ - {Name: "//:prepare"}, - {Name: "//apps/api:prepare"}, - {Name: "//apps/api:dev", Dependencies: []string{"//apps/api:prepare", "//:prepare"}}, - {Name: "//:web:dev", Dependencies: []string{"//:prepare"}}, - {Name: "//:dev", Dependencies: []string{"//apps/api:dev", "//:web:dev"}}, - }} - if got := taskGraph(p); !reflect.DeepEqual(got, want) { - t.Fatalf("graph=%+v want=%+v", got, want) - } - if p.Tasks[4].Dependencies[1] != "web:*" || p.Entries[0] != "//:start" { - t.Fatal("display graph changed scheduler plan") - } -} - -func TestTaskGraphWaitEdgesOnlyReferenceSelectedTasks(t *testing.T) { - p := &Plan{Entries: []string{"//:dev"}, Tasks: []Task{{Name: "//:dev", Dependencies: []string{"//:api"}}, {Name: "//:api", waitFor: []string{"//:ready", "//:outside"}}, {Name: "//:ready"}}} - graph := taskGraph(p) - if len(graph.Tasks) != 3 || !reflect.DeepEqual(graph.Tasks[1].WaitFor, []string{"//:ready"}) { - t.Fatal("waiting edge changed invocation scope", graph) - } - if len(p.Tasks[1].waitFor) != 2 { - t.Fatal("display projection mutated scheduling plan") - } -} diff --git a/packages/cli/internal/modules/tasks/testdata/catalog-en-US.txt b/packages/cli/internal/modules/tasks/testdata/catalog-en-US.txt deleted file mode 100644 index bd28889a..00000000 --- a/packages/cli/internal/modules/tasks/testdata/catalog-en-US.txt +++ /dev/null @@ -1,15 +0,0 @@ -Workspace tasks (2) - build Build workspace applications - install - -Project: api (1) - //apps/api:test Run API tests - -Project: web (1) - web:dev Start the web app - -Scope: //tools (1) - //tools:check Check tooling - -Run a task: one run -Filter: one run -p · Details: one run --verbose diff --git a/packages/cli/internal/modules/tasks/testdata/catalog-zh-CN.txt b/packages/cli/internal/modules/tasks/testdata/catalog-zh-CN.txt deleted file mode 100644 index 0c30b61c..00000000 --- a/packages/cli/internal/modules/tasks/testdata/catalog-zh-CN.txt +++ /dev/null @@ -1,15 +0,0 @@ -工作区任务(2) - build Build workspace applications - install - -项目:api(1) - //apps/api:test Run API tests - -项目:web(1) - web:dev Start the web app - -作用域://tools(1) - //tools:check Check tooling - -执行任务:one run <任务名> -筛选项目:one run -p <项目名> · 查看详情:one run --verbose diff --git a/packages/cli/internal/modules/taskui/copy_test.go b/packages/cli/internal/modules/taskui/copy_test.go deleted file mode 100644 index a7d9d3b9..00000000 --- a/packages/cli/internal/modules/taskui/copy_test.go +++ /dev/null @@ -1,228 +0,0 @@ -package taskui - -import ( - tea "charm.land/bubbletea/v2" - "context" - "errors" - "github.com/charmbracelet/x/ansi" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" - "strings" - "testing" -) - -func TestCopyLogicalRangesKeepUnicodeBlankLinesAndNoSoftBreaks(t *testing.T) { - source := []string{"\x1b[32m中文🧑‍💻é URL\x1b[0m", "", " indented"} - if got := textRange(source, cursor{}, cursor{2, 10}); got != "中文🧑‍💻é URL\n\n indented" { - t.Fatalf("lost Unicode or indentation: %q", got) - } - frame := ©Frame{lines: source, selected: true, anchor: cursor{0, 4}, head: cursor{0, 5}} - a, b := frame.selection() - if got := textRange(source, a, b); got != "🧑‍💻" { - t.Fatalf("split emoji: %q", got) - } - frame.anchor, frame.head = frame.head, frame.anchor - a, b = frame.selection() - if got := textRange(source, a, b); got != "🧑‍💻" { - t.Fatal("reverse drag changed text", got) - } - logs := testStore(t) - text := strings.Repeat("中文é🧑‍💻", 30) - writeLog(t, newLogWriter(logs), text+"\n") - m := newModel(logs, Graph{}) - m.width = 45 - m.height = 100 - m.freeze() - m.copy.selected = true - m.copy.anchor = cursor{} - m.copy.head = cursor{0, ansi.StringWidth(text)} - if got := m.selectedText(); got != text || strings.Contains(got, "\n") { - t.Fatal("soft wraps became real newlines", got) - } - before := m.selectedText() - updated, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) - m = updated.(model) - if m.selectedText() != before { - t.Fatal("resize changed logical selection") - } -} -func TestDragSelectionFreezesProgressAndResumeReadsNewLogs(t *testing.T) { - logs := testStore(t) - writer := newLogWriter(logs) - writeLog(t, writer, "before\nprogress 中文") - m := newModel(logs, Graph{}) - left, _, _ := m.layout() - y := len(m.headerLines()) - updated, _ := m.Update(tea.MouseClickMsg{X: left + 2, Y: y, Button: tea.MouseLeft}) - m = updated.(model) - updated, _ = m.Update(tea.MouseMotionMsg{X: left + 7, Y: y, Button: tea.MouseLeft}) - m = updated.(model) - updated, _ = m.Update(tea.MouseReleaseMsg{X: left + 7, Y: y, Button: tea.MouseLeft}) - m = updated.(model) - if got := m.selectedText(); got != "before" { - t.Fatal("mouse coordinates copied decorations", got) - } - frozen := plainPage(m) - writeLog(t, writer, "\rchanged\nnew output\n") - updated, _ = m.Update(tickMsg{}) - m = updated.(model) - if plainPage(m) != frozen || m.selectedText() != "before" { - t.Fatal("new output changed selected snapshot") - } - m = key(m, 'c', "c") - if view := m.View(); view.MouseMode != tea.MouseModeNone || strings.Contains(view.Content, "new output") || strings.Contains(view.Content, "│ ") { - t.Fatal("native copy not a fixed full-width log view") - } - m = key(m, tea.KeyEscape, "") - if m.View().MouseMode != tea.MouseModeCellMotion || !strings.Contains(plainPage(m), "new output") { - t.Fatal("resume did not restore live logs and mouse") - } -} -func TestCopyMenuHistorySearchAndFailure(t *testing.T) { - logs := testStore(t, "//:api") - writer := newLogWriter(logs) - writeLog(t, writer, "[//:api\t] old 中文🧑‍💻\n[//:api\t] unmatched\n[//:api\t] latest\n") - m := newModel(logs, Graph{Tasks: []Task{{Name: "//:api"}}}) - m.selected = 1 - var copied string - m.clipboard = func(_ context.Context, text string) (bool, error) { copied = text; return false, nil } - updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'y', Text: "y"})) - m = updated.(model) - updated, cmd := m.Update(tea.KeyPressMsg(tea.Key{Code: 'n', Text: "n"})) - m = updated.(model) - updated, _ = m.Update(cmd()) - m = updated.(model) - if copied != "//:api" || m.copyNote != i18n.T("tasks.ui.copied") { - t.Fatal("full name copy failed") - } - m.query = "old" - m.matches = []int{0} - cmd = m.historyCopyCmd() - writeLog(t, writer, "[//:api\t] old after request\n") - cmd() - if !strings.Contains(copied, "old 中文🧑‍💻") || strings.Contains(copied, "unmatched") || strings.Contains(copied, "after request") || strings.Contains(copied, "\x1b") { - t.Fatal("history snapshot/search leaked records", copied) - } - m.query = "" - m.freeze() - m.copy.selected = true - m.copy.anchor = cursor{} - m.copy.head = cursor{0, 3} - selection := m.selectedText() - m.clipboard = func(context.Context, string) (bool, error) { return false, errors.New("clipboard unavailable") } - cmd = m.clipboardCmd(selection) - updated, _ = m.Update(cmd()) - m = updated.(model) - if m.selectedText() != selection || !strings.Contains(m.copyNote, "clipboard unavailable") { - t.Fatal("copy failure lost selection or message") - } - m.clipboard = func(context.Context, string) (bool, error) { return true, nil } - updated, cmd = m.Update(m.clipboardCmd(selection)()) - m = updated.(model) - if cmd == nil || m.copyNote != i18n.T("tasks.ui.copy_requested") { - t.Fatal("OSC request incorrectly reported success") - } - m.search = true - m.editQuery = "" - m = key(m, 'y', "y") - m = key(m, 'c', "c") - if m.editQuery != "yc" { - t.Fatal("copy intercepted search input") - } -} -func TestCopySameTaskAndFullHistoryScope(t *testing.T) { - graph := sharedGraph() - logs := testStore(t, graph.names()...) - writer := newLogWriter(logs) - for range 100 { - writeLog(t, writer, "[//:prepare\t] shared\n[//:web:dev\t] other\n") - } - m := newModel(logs, graph) - m.selected = m.tree.primary["//:prepare"] - m.freeze() - frame := m.copy - for id, node := range m.tree.nodes { - if node.reference && node.task == m.task() { - m.selectTask(id) - } - } - if m.copy != frame { - t.Fatal("same task reference cleared selection") - } - var text string - m.clipboard = func(_ context.Context, value string) (bool, error) { text = value; return false, nil } - m.historyCopyCmd()() - if strings.Count(text, "shared") != 100 || strings.Contains(text, "other") { - t.Fatal("history truncated or mixed tasks") - } - m.selectTask(m.tree.primary["//:web:dev"]) - if m.copy != nil { - t.Fatal("selection leaked to another task") - } -} -func TestRemoteClipboardLimitAndContext(t *testing.T) { - t.Setenv("SSH_CONNECTION", "remote") - remote, err := systemClipboard(context.Background(), "safe 中文") - if !remote || err != nil { - t.Fatal("remote used desktop clipboard", err) - } - _, err = systemClipboard(context.Background(), strings.Repeat("x", 100*1024+1)) - if err == nil { - t.Fatal("large OSC request was silently truncated") - } -} - -func TestFrozenCopyLayoutsFitTinyTerminalsAndBothLocales(t *testing.T) { - old := i18n.Active() - defer i18n.Init(old) - for _, locale := range []string{"en-US", "zh-CN"} { - i18n.Init(locale) - logs := testStore(t) - writeLog(t, newLogWriter(logs), "中文🧑‍💻 Unicode long line........................................................\n") - for _, native := range []bool{false, true} { - m := newModel(logs, Graph{}) - m.freeze() - m.copy.native = native - for _, size := range [][2]int{{1, 1}, {1, 12}, {2, 2}, {2, 10}, {38, 8}, {80, 24}, {140, 30}} { - updated, _ := m.Update(tea.WindowSizeMsg{Width: size[0], Height: size[1]}) - m = updated.(model) - lines := strings.Split(m.View().Content, "\n") - if len(lines) > size[1] { - t.Fatal("copy layout height overflow", size) - } - for _, line := range lines { - if ansi.StringWidth(line) > size[0] { - t.Fatal("copy layout width overflow", size, line) - } - } - } - } - } -} - -func TestDragSelectsLastDisplayedFrameDespiteFastNewLogs(t *testing.T) { - logs := testStore(t) - writer := newLogWriter(logs) - writeLog(t, writer, "VISIBLE_BEFORE 中文\n") - m := newModel(logs, Graph{}) - view := m.View() - if !strings.Contains(view.Content, "VISIBLE_BEFORE") { - t.Fatal("old frame not rendered") - } - for range 1000 { - writeLog(t, writer, "NEW_UNDISPLAYED\n") - } - left, _, _ := m.layout() - y := len(m.headerLines()) - m.startSelection(left+2, y) - m.copy.head = m.mouseCursor(left+16, y) - if text := m.selectedText(); text != "VISIBLE_BEFORE " { - t.Fatalf("selected new unseen logs: %q", text) - } - if page := plainPage(m); strings.Contains(page, "NEW_UNDISPLAYED") { - t.Fatal("frozen view changed at click") - } - m.resumeCopy(true) - if page := plainPage(m); !strings.Contains(page, "NEW_UNDISPLAYED") { - t.Fatal("follow did not restore live output") - } -} diff --git a/packages/cli/internal/modules/taskui/prefix_test.go b/packages/cli/internal/modules/taskui/prefix_test.go deleted file mode 100644 index 5411d626..00000000 --- a/packages/cli/internal/modules/taskui/prefix_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package taskui - -import ( - "slices" - "strings" - "testing" - - uv "github.com/charmbracelet/ultraviolet" - "github.com/charmbracelet/x/ansi" -) - -func TestTaskPrefixColorsKeepIdentityAndBodyStyles(t *testing.T) { - names := []string{"//:admin:dev", "//:_node:commercial-web:build", "//:server:dev"} - s := testStore(t, names...) - stdout, stderr := newLogWriter(s), newLogWriter(s) - writeLog(t, stdout, "[admin:dev ] \x1b[1;31m中文 error\n") - writeLog(t, stderr, "[//:admin:dev\t] stderr\n") - writeLog(t, stdout, "[//:_node:commercial-web:build] normal\n[server:dev] server\n[//:admin:dev] still red\x1b[0m\n[unknown] \x1b[32muntouched\x1b[0m\n") - colors := map[ansi.IndexedColor]bool{} - for _, id := range []int{0, 2, 3} { - cells := uv.NewStyledString(s.read(id)).Lines(ansi.GraphemeWidth)[0] - color, ok := cells[0].Style.Fg.(ansi.IndexedColor) - if !ok || colors[color] { - t.Fatal("task prefixes are not distinguishable", s.read(id)) - } - colors[color] = true - } - first := uv.NewStyledString(s.read(0)).Lines(ansi.GraphemeWidth)[0] - if got := ansi.Strip(s.read(1)); got != "[//:admin:dev] stderr" { - t.Fatalf("scheduler tab padding retained: %q", got) - } - for _, id := range []int{1, 4} { - cells := uv.NewStyledString(s.read(id)).Lines(ansi.GraphemeWidth)[0] - if cells[0].Style.Fg != first[0].Style.Fg { - t.Fatal("alias or output stream changed the task color") - } - } - for _, id := range []int{0, 4} { - cells := uv.NewStyledString(s.read(id)).Lines(ansi.GraphemeWidth)[0] - if cells[len(cells)-1].Style != first[len(first)-1].Style || cells[len(cells)-1].Style.Fg != ansi.BasicColor(1) { - t.Fatal("prefix color overwrote the child's red/bold style") - } - } - for _, id := range []int{1, 2, 3} { - cells := uv.NewStyledString(s.read(id)).Lines(ansi.GraphemeWidth)[0] - if !cells[len(cells)-1].Style.IsZero() { - t.Fatal("prefix or other writer's color leaked into the body") - } - } - unknown := uv.NewStyledString(s.read(5)).Lines(ansi.GraphemeWidth)[0] - if ansi.Strip(s.read(5)) != "[unknown] untouched" || !unknown[0].Style.IsZero() || unknown[len(unknown)-1].Style.Fg != ansi.BasicColor(2) { - t.Fatal("unrecognized log prefix was recolored", s.read(5)) - } - if got := textRange([]string{s.read(0), s.read(2)}, cursor{}, cursor{1, ansi.StringWidth(s.read(2))}); got != "[admin:dev] 中文 error\n[//:_node:commercial-web:build] normal" || strings.Contains(got, "\x1b") { - t.Fatalf("prefix colors changed copied text: %q", got) - } - // The same graph gets the same colors even if discovery order changes. - ordered := slices.Clone(names) - slices.Reverse(ordered) - other := testStore(t, ordered...) - for _, name := range names { - if s.prefixStyles[name] != other.prefixStyles[name] { - t.Fatal("graph discovery order changed colors", name) - } - } -} diff --git a/packages/cli/internal/modules/taskui/sidebar_test.go b/packages/cli/internal/modules/taskui/sidebar_test.go deleted file mode 100644 index 33dd4826..00000000 --- a/packages/cli/internal/modules/taskui/sidebar_test.go +++ /dev/null @@ -1,162 +0,0 @@ -package taskui - -import ( - tea "charm.land/bubbletea/v2" - "fmt" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" - "reflect" - "strings" - "testing" - "time" -) - -func TestSilentRunningTasksAndCompletionKeepIdentity(t *testing.T) { - graph := sharedGraph() - logs := testStore(t, graph.names()...) - m := newModel(logs, graph) - now := time.Now() - states := []State{{Name: "//:prepare", Status: "succeeded"}, {Name: "//:web:dev", Status: "running", StartedAt: now}, {Name: "//:api:dev", Status: "running", StartedAt: now.Add(-time.Second)}} - m.source = func() []State { return states } - m.refreshStates() - if got := m.runningTasks(); !reflect.DeepEqual(got, []string{"//:api:dev", "//:web:dev"}) { - t.Fatal(got) - } - if logs.count("") != 0 { - t.Fatal("fixture should be silent") - } - m.navigateTask(1) - if !m.runningFocus || m.task() != "//:api:dev" { - t.Fatal("running shortcut not selected") - } - writeLog(t, newLogWriter(logs), "[//:api:dev\t] API_ONLY\n[//:web:dev\t] WEB_ONLY\n") - m.readings[m.task()] = reading{top: cursor{}, seen: 1} - m.query = "API" - m.matches = []int{0} - m.generation = 7 - m.freeze() - before := m.reading() - frame := m.copy - states[2].Status = "succeeded" - m.refreshStates() - if m.runningFocus || m.task() != "//:api:dev" || m.reading() != before || m.copy != frame || m.generation != 7 || m.query != "API" { - t.Fatal("completion changed selected logs or reading state") - } - if m.tree.collapsed[m.tree.primary["//:dev"]] { - t.Fatal("completion failed to reveal selected node") - } - if page := plainPage(m); !strings.Contains(page, "API_ONLY") || strings.Contains(page, "WEB_ONLY") { - t.Fatal(page) - } - if len(m.runningTasks()) != 1 { - t.Fatal("finished task remains running") - } - if !strings.Contains(m.treeLabel(treeRow{id: m.selected}), "✓") { - t.Fatal("tree lost completed status") - } -} -func TestSidebarIndependentScrollAndStableSections(t *testing.T) { - graph := Graph{} - for i := range 40 { - graph.Tasks = append(graph.Tasks, Task{Name: fmt.Sprintf("//:task-%02d", i)}) - } - m := newModel(testStore(t, graph.names()...), graph) - m.height = 24 - for i, task := range graph.Tasks { - m.states[task.Name] = State{Name: task.Name, Status: "running", StartedAt: time.Unix(int64(i), 0)} - } - runHeight, treeHeight := m.sidebarHeights() - m.wheelSidebar(2, 10) - if m.runningOffset != 10 || m.treeOffset != 0 { - t.Fatal("running wheel changed tree") - } - m.wheelSidebar(3+runHeight, 10) - if m.treeOffset != 10 || m.runningOffset != 10 { - t.Fatal("tree wheel changed running list") - } - m.selected = m.tree.primary[graph.Tasks[30].Name] - m.runningFocus = true - m.ensureSidebarSelection() - if m.runningOffset <= 10 || m.treeOffset != 10 { - t.Fatal("selection failed to scroll its own section") - } - m.states[graph.Tasks[0].Name] = State{Status: "succeeded"} - if a, b := m.sidebarHeights(); a != runHeight || b != treeHeight { - t.Fatal("completion moved tree section") - } - m.taskFocus = true - m = key(m, tea.KeyRight, "") - if m.runningFocus || m.task() != graph.Tasks[30].Name { - t.Fatal("right failed to locate same identity in tree") - } - if !strings.Contains(strings.Join(sidebarLabels(m), "\n"), "task-30") { - t.Fatal("located node not visible") - } -} -func sidebarLabels(m model) []string { - var labels []string - for _, row := range m.sidebarRows() { - labels = append(labels, row.label) - } - return labels -} -func TestInitialTreeDepthAndWaitReferences(t *testing.T) { - graph := sharedGraph() - graph.Tasks[2].WaitFor = []string{"//:api:dev"} - m := newModel(testStore(t, graph.names()...), graph) - if !m.tree.collapsed[m.tree.primary["//:api:dev"]] || m.tree.collapsed[m.tree.primary["//:dev"]] { - t.Fatal("initial tree did not expose just entries and first dependencies") - } - m.reveal(m.tree.primary["//:prepare"]) - if m.tree.collapsed[m.tree.primary["//:api:dev"]] { - t.Fatal("reveal did not expand ancestors") - } - found := false - for id, node := range m.tree.nodes { - if node.wait { - found = true - if !strings.Contains(m.treeLabel(treeRow{id: id}), "◇") { - t.Fatal("waiting edge lost") - } - } - } - if !found { - t.Fatal("wait edge missing") - } - old := i18n.Active() - defer i18n.Init(old) - for _, locale := range []string{"zh-CN", "en-US"} { - i18n.Init(locale) - if view := m.View().Content; !strings.Contains(view, i18n.T("tasks.ui.no_running")) { - t.Fatal("empty state untranslated", view) - } - } -} - -func TestStateRefreshDoesNotUndoIndependentWheelBrowsing(t *testing.T) { - graph := Graph{} - states := []State{} - for i := range 40 { - name := fmt.Sprintf("//:task-%02d", i) - graph.Tasks = append(graph.Tasks, Task{Name: name}) - states = append(states, State{Name: name, Status: "running", StartedAt: time.Unix(int64(i), 0)}) - } - m := newModel(testStore(t, graph.names()...), graph) - m.source = func() []State { return states } - m.refreshStates() - m.navigateSidebar(1) - m.wheelSidebar(2, 10) - offset := m.runningOffset - m.refreshStates() - if m.runningOffset != offset { - t.Fatal("state tick undid running-list browsing") - } - m.runningFocus = false - m.selected = m.tree.primary[states[0].Name] - runHeight, _ := m.sidebarHeights() - m.wheelSidebar(3+runHeight, 10) - offset = m.treeOffset - m.refreshStates() - if m.treeOffset != offset { - t.Fatal("state tick undid tree browsing") - } -} diff --git a/packages/cli/internal/modules/taskui/tree_test.go b/packages/cli/internal/modules/taskui/tree_test.go deleted file mode 100644 index 8bb2e0de..00000000 --- a/packages/cli/internal/modules/taskui/tree_test.go +++ /dev/null @@ -1,199 +0,0 @@ -package taskui - -import ( - "reflect" - "strings" - "testing" - - tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/x/ansi" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" -) - -func sharedGraph() Graph { - return Graph{Entries: []string{"//:dev"}, Tasks: []Task{ - {Name: "//:prepare"}, - {Name: "//:api:dev", Dependencies: []string{"//:prepare"}}, - {Name: "//:web:dev", Dependencies: []string{"//:prepare"}}, - {Name: "//:dev", Dependencies: []string{"//:api:dev", "//:web:dev"}}, - }} -} -func TestDependencyTreePreservesSharedEdges(t *testing.T) { - tree := newTaskTree(sharedGraph()) - var labels []string - for _, row := range tree.visible()[1:] { - labels = append(labels, tree.label(row)) - } - want := []string{"▾ dev", "├─ ▾ api:dev", "│ └─ prepare", "└─ ▾ web:dev", " └─ ↪ prepare"} - if !reflect.DeepEqual(labels, want) { - t.Fatalf("tree=%q want=%q", labels, want) - } - // Multiple explicit entries remain roots even if one is also a dependency. - graph := sharedGraph() - graph.Entries = []string{"//:api:dev", "//:web:dev", "//:prepare"} - tree = newTaskTree(graph) - if roots := tree.nodes[0].children; len(roots) != 4 || !tree.nodes[roots[2]].reference { - t.Fatalf("entries or disconnected task lost: %+v", tree.nodes) - } - graph = Graph{Tasks: []Task{{Name: "a", Dependencies: []string{"b"}}, {Name: "b", Dependencies: []string{"a", "missing"}}}} - tree = newTaskTree(graph) - if len(tree.nodes) != 4 || !tree.nodes[3].reference { - t.Fatalf("cycle not bounded: %+v", tree.nodes) - } -} -func TestTreeNavigationSharesTaskLogsAndReadingPosition(t *testing.T) { - graph := sharedGraph() - s := testStore(t, graph.names()...) - w := newLogWriter(s) - for range 40 { - writeLog(t, w, "[prepare] shared output\n[web:dev] web only\n") - } - m := newModel(s, graph) - m.height = 14 - m = key(m, tea.KeyTab, "") - m = key(m, tea.KeyDown, "") // dev - m = key(m, tea.KeyLeft, "") // collapse dev - if len(m.tree.visible()) != 2 { - t.Fatal("root did not collapse") - } - m = key(m, tea.KeyRight, "") // expand dev - m = key(m, tea.KeyRight, "") // api - m = key(m, tea.KeyRight, "") // expand api - m = key(m, tea.KeyRight, "") // prepare (primary) - primary := m.selected - if m.task() != "//:prepare" { - t.Fatal("wrong dependency selected", m.task()) - } - m = key(m, tea.KeyTab, "") // logs - m = key(m, tea.KeyHome, "") - m = key(m, tea.KeyDown, "") - position := m.reading() - m = key(m, tea.KeyTab, "") // tree - delete(m.tree.collapsed, m.tree.primary["//:web:dev"]) - m = key(m, tea.KeyEnd, "") // prepare (reference) - if !m.tree.nodes[m.selected].reference || m.reading() != position { - t.Fatal("shared reference lost reading position") - } - if page := plainPage(m); !strings.Contains(page, "shared output") || strings.Contains(page, "web only") { - t.Fatal("reference selected wrong logs", page) - } - // A search stays valid when selecting another occurrence of the same task. - m.query = "shared" - cmd := m.beginSearch(true) - updated, _ := m.Update(cmd()) - m = updated.(model) - generation := m.generation - m.tree.collapsed[m.tree.primary["//:api:dev"]] = true - updated, cmd = m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter})) - m = updated.(model) - if cmd != nil || m.selected != primary || m.tree.collapsed[m.tree.primary["//:api:dev"]] || m.generation != generation || len(m.matches) != 40 { - t.Fatal("reference jump did not reveal primary with same search") - } - // Switching to a different node must search only that task's history. - updated, cmd = m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyDown})) - m = updated.(model) - if m.task() != "//:web:dev" || cmd == nil { - t.Fatal("task switch did not restart search") - } - updated, _ = m.Update(cmd()) - m = updated.(model) - if len(m.matches) != 0 { - t.Fatal("search results leaked between tasks") - } -} -func TestTreeMouseTargetsVisibleNodes(t *testing.T) { - graph := sharedGraph() - m := newModel(testStore(t, graph.names()...), graph) - m.selected = m.tree.primary["//:api:dev"] - m.tree.collapsed[m.selected] = true - targetY := 0 - for i, row := range m.sidebarRows() { - if row.id == m.tree.primary["//:web:dev"] { - targetY = i + len(m.headerLines()) - } - } - updated, _ := m.Update(tea.MouseClickMsg{X: 3, Y: targetY, Button: tea.MouseLeft}) - m = updated.(model) - if m.task() != "//:web:dev" || !m.taskFocus { - t.Fatal("click selected a hidden node", m.task()) - } - selected := m.selected - updated, _ = m.Update(tea.MouseWheelMsg{X: 3, Y: targetY, Button: tea.MouseWheelDown}) - m = updated.(model) - if m.selected != selected || !m.reading().follow { - t.Fatal("tree wheel moved selection or scrolled logs") - } - - updated, _ = m.Update(tea.MouseWheelMsg{X: 75, Y: 4, Button: tea.MouseWheelUp}) - m = updated.(model) - if m.reading().follow { - t.Fatal("log wheel navigated tree") - } -} -func TestTreeResizeAndLocales(t *testing.T) { - old := i18n.Active() - t.Cleanup(func() { i18n.Init(old) }) - graph := Graph{Entries: []string{"//:开发"}, Tasks: []Task{ - {Name: "//:开发", Dependencies: []string{"//:中文🧑‍💻任务很长很长很长很长很长很长"}}, - {Name: "//:中文🧑‍💻任务很长很长很长很长很长很长"}, - }} - s := testStore(t, graph.names()...) - writeLog(t, newLogWriter(s), "[开发] LOG_VISIBLE\n") - for _, locale := range []string{"zh-CN", "en-US"} { - i18n.Init(locale) - for _, size := range [][2]int{{1, 1}, {2, 2}, {38, 10}, {69, 20}, {70, 20}, {110, 25}, {180, 30}} { - for _, focus := range []bool{false, true} { - m := newModel(s, graph) - m.width, m.height, m.taskFocus = size[0], size[1], focus - lines := strings.Split(m.View().Content, "\n") - if len(lines) > m.height { - t.Fatal("height overflow", size) - } - for _, line := range lines { - if ansi.StringWidth(line) > m.width { - t.Fatalf("width overflow %v: %q", size, line) - } - } - } - } - m := newModel(s, graph) - m.width = 45 - if !strings.Contains(m.View().Content, "LOG_VISIBLE") { - t.Fatal("narrow log pane missing") - } - m = key(m, tea.KeyTab, "") - if view := m.View().Content; strings.Contains(view, "LOG_VISIBLE") || !strings.Contains(view, "└─ ") || !strings.Contains(view, i18n.T("tasks.ui.focus_tasks")) { - t.Fatal("narrow tree pane missing", view) - } - m = key(m, tea.KeyTab, "") - if !strings.Contains(m.View().Content, "LOG_VISIBLE") { - t.Fatal("tab did not restore narrow logs") - } - } -} - -func TestTreeClickUsesRowsBeforeFocusChangesFooter(t *testing.T) { - old := i18n.Active() - i18n.Init("en-US") - t.Cleanup(func() { i18n.Init(old) }) - graph := Graph{} - for _, name := range []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"} { - graph.Tasks = append(graph.Tasks, Task{Name: name}) - } - m := newModel(testStore(t, graph.names()...), graph) - m.width, m.height, m.selected = 80, 10, len(graph.Tasks) - m.ensureSidebarSelection() - rows := m.sidebarRows() - index := 0 - for i, row := range rows { - if row.id > 0 { - index = i - break - } - } - want := rows[index].id - updated, _ := m.Update(tea.MouseClickMsg{X: 3, Y: len(m.headerLines()) + index, Button: tea.MouseLeft}) - if got := updated.(model); got.selected != want { - t.Fatalf("focus changed clicked row: got=%d want=%d", got.selected, want) - } -} diff --git a/packages/cli/internal/modules/taskui/ui_test.go b/packages/cli/internal/modules/taskui/ui_test.go deleted file mode 100644 index b3593791..00000000 --- a/packages/cli/internal/modules/taskui/ui_test.go +++ /dev/null @@ -1,428 +0,0 @@ -package taskui - -import ( - "bytes" - "context" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "sync" - "testing" - - tea "charm.land/bubbletea/v2" - uv "github.com/charmbracelet/ultraviolet" - "github.com/charmbracelet/x/ansi" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" -) - -func testStore(t *testing.T, names ...string) *logStore { - t.Helper() - s, err := newLogStore(names) - if err != nil { - t.Fatal(err) - } - t.Cleanup(s.close) - return s -} -func writeLog(t *testing.T, w *logWriter, text string) { - t.Helper() - if _, err := w.Write([]byte(text)); err != nil { - t.Fatal(err) - } -} -func key(m model, code rune, text string) model { - next, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: code, Text: text})) - return next.(model) -} -func plainPage(m model) string { return ansi.Strip(strings.Join(m.page(), "\n")) } - -func TestFullHistoryJournalAndCleanup(t *testing.T) { - s := testStore(t, "//:web:dev") - w := newLogWriter(s) - writeLog(t, w, "\x1b[32m[web:") - writeLog(t, w, "dev]\x1b[0m 中文 ready\nuntagged\n") - for i := 0; i < 10000; i++ { - writeLog(t, w, fmt.Sprintf("[web:dev] %05d %s\n", i, strings.Repeat("x", 300))) - } - if s.count("") != 10002 || s.count("//:web:dev") != 10001 { - t.Fatal("lost log records") - } - if got := s.read(0); ansi.Strip(got) != "[web:dev] 中文 ready" || got == ansi.Strip(got) { - t.Fatalf("prefix/style lost: %q", got) - } - if !strings.Contains(s.read(10001), "09999") { - t.Fatal("tail lost") - } - info, err := os.Stat(s.file.Name()) - if err != nil || info.Size() < 2<<20 { - t.Fatal("journal not complete", err) - } - if runtime.GOOS != "windows" && info.Mode().Perm()&0077 != 0 { - t.Fatal("journal accessible to other users") - } - path := s.file.Name() - s.close() - if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { - t.Fatal("journal not removed") - } -} -func TestStylesBlankLinesLongLinesAndProgress(t *testing.T) { - s := testStore(t, "//:web:dev", "//:api:dev") - w := newLogWriter(s) - input := "\x1b[35m[web:dev\t]\x1b[0m \x1b[1;3;4;38;2;12;34;56m中文 👩‍💻\n[api:dev] normal\n[web:dev] still styled\x1b[0m\n\n[web:dev ] spaced\tcolumn\n[web:dev] progress 123\r\x1b[2Kdone\n[web:dev] abc\bZ\n" - // Split every UTF-8 byte and every escape sequence across writes. - for i := range len(input) { - writeLog(t, w, input[i:i+1]) - } - if s.count("") != 7 { - t.Fatal(s.count("")) - } - if s.read(3) != "" { - t.Fatal("blank line lost") - } - if ansi.Strip(s.read(4)) != "[web:dev] spaced column" { - t.Fatalf("indent/tab %q", ansi.Strip(s.read(4))) - } - if ansi.Strip(s.read(5)) != "[web:dev] done" || ansi.Strip(s.read(6)) != "[web:dev] abZ" { - t.Fatal("line editing failed", s.read(5), s.read(6)) - } - styled := uv.NewStyledString(s.read(2)).Lines(ansi.GraphemeWidth)[0] - last := styled[len(styled)-1] - if last.Style.Attrs&uv.AttrBold == 0 || last.Style.Attrs&uv.AttrItalic == 0 || last.Style.Underline == uv.UnderlineStyleNone || last.Style.Fg == nil { - t.Fatal("style did not survive a task switch", last.Style) - } - normal := uv.NewStyledString(s.read(1)).Lines(ansi.GraphemeWidth)[0] - if !normal[len(normal)-1].Style.IsZero() { - t.Fatal("style leaked into another task") - } - long := strings.Repeat("中文x", 20000) - writeLog(t, w, long+"\n") - if ansi.Strip(s.read(7)) != long { - t.Fatal("long line truncated") - } - writeLog(t, w, "[web:dev] pending") - if got := s.unfinished(""); len(got) != 1 || ansi.Strip(got[0]) != "[web:dev] pending" { - t.Fatal("partial line invisible", got) - } - w.flush() - if s.count("") != 9 || len(s.unfinished("")) != 0 { - t.Fatal("flush lost or duplicated partial line") - } -} -func TestControlsCannotEscapeLogPane(t *testing.T) { - input := "\x1b]0;title\x07\x1b[2J\x1b[?1049l\x1b]52;c;abc\x07safe\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\\x1b[31" - text, _ := renderText(input, pen{}) - for _, bad := range []string{"title", "52;", "2J", "1049", "[31"} { - if strings.Contains(text, bad) { - t.Fatalf("control leaked: %q", text) - } - } - if ansi.Strip(text) != "safelink" || !strings.Contains(text, "https://example.com") { - t.Fatal(text) - } -} -func TestWrapRetainsPrefixStylesAndEveryGrapheme(t *testing.T) { - text := "\x1b[34m[//:web:dev]\x1b[0m \x1b[1;31m 中文 👩‍💻 é " + strings.Repeat("long", 40) + " \x1b[0m" - for _, width := range []int{2, 17, 45, 80, 180} { - rows := wrapText(text, width) - var b strings.Builder - for _, row := range rows { - if ansi.StringWidth(row.text) > width { - t.Fatalf("overflow at %d: %q", width, row.text) - } - b.WriteString(ansi.Strip(row.text)) - cells := uv.NewStyledString(row.text + "!").Lines(ansi.GraphemeWidth)[0] - if !cells[len(cells)-1].Style.IsZero() { - t.Fatal("style leaked past log row") - } - } - if b.String() != ansi.Strip(text) { - t.Fatalf("text lost at width %d: %q", width, b.String()) - } - } -} -func TestScrollResizeAnchorAndTaskPositions(t *testing.T) { - s := testStore(t, "//:web:dev") - w := newLogWriter(s) - for i := 0; i < 100; i++ { - writeLog(t, w, fmt.Sprintf("[web:dev] line-%03d %s\n", i, strings.Repeat("中文", 40))) - } - m := testModel(s) - m.height = 12 - m.width = 110 - m = key(m, tea.KeyHome, "") - if !strings.Contains(plainPage(m), "line-000") { - t.Fatal("home missed first log") - } - m = key(m, tea.KeyPgDown, "") - before := m.top() - original := s.read(before.line) - m2, _ := m.Update(tea.WindowSizeMsg{Width: 45, Height: 10}) - m = m2.(model) - after := m.top() - if after.line != before.line || after.column > before.column { - t.Fatal("resize lost anchor", before, after) - } - if s.read(after.line) != original { - t.Fatal("anchor points to different log") - } - for _, width := range []int{27, 75, 38, 110} { - next, _ := m.Update(tea.WindowSizeMsg{Width: width, Height: 12}) - m = next.(model) - } - if m.top() != before { - t.Fatal("repeated resize drifted", before, m.top()) - } - held := plainPage(m) - for i := 0; i < 30; i++ { - writeLog(t, w, "[web:dev] NEW OUTPUT\n") - } - if got := plainPage(m); got != held { - t.Fatal("history jumped when output arrived") - } - m = key(m, ']', "]") - m = key(m, tea.KeyHome, "") - m = key(m, 'a', "a") - if plainPage(m) != held { - t.Fatal("task switch lost position") - } - m = key(m, tea.KeyEnd, "") - if !m.reading().follow || !strings.Contains(plainPage(m), "NEW OUTPUT") { - t.Fatal("end did not follow") - } - mouse, _ := m.Update(tea.MouseWheelMsg{Button: tea.MouseWheelUp}) - m = mouse.(model) - if m.reading().follow { - t.Fatal("wheel did not pause") - } - m = key(m, 'f', "f") - if !m.reading().follow { - t.Fatal("f did not follow") - } -} -func TestSearchReachesOldLogsAndPreservesColors(t *testing.T) { - s := testStore(t, "//:web:dev") - w := newLogWriter(s) - writeLog(t, w, "[web:dev] \x1b[31mFIRST_NEEDLE\x1b[0m\n") - for range 6000 { - writeLog(t, w, "[web:dev] filler\n") - } - m := testModel(s) - m.query = "needle" - cmd := m.beginSearch(true) - changed, _ := m.Update(cmd()) - m = changed.(model) - if len(m.matches) != 1 || !strings.Contains(plainPage(m), "FIRST_NEEDLE") || !strings.Contains(strings.Join(m.page(), ""), "\x1b[31m") { - t.Fatal("history search failed") - } - writeLog(t, w, "[web:dev] SECOND_NEEDLE\n") - cmd = m.beginSearch(false) - changed, _ = m.Update(cmd()) - m = changed.(model) - if len(m.matches) != 2 { - t.Fatal("new matches missing") - } -} -func TestFocusAndCancellation(t *testing.T) { - s := testStore(t, "//:web:dev") - m := testModel(s) - cancelled := false - m.cancel = func() { cancelled = true } - m = key(m, tea.KeyTab, "") - m = key(m, tea.KeyDown, "") - if m.selected != 1 { - t.Fatal("task focus failed") - } - m = key(m, tea.KeyTab, "") - m = key(m, tea.KeyUp, "") - if m.selected != 1 { - t.Fatal("scroll changed task") - } - _, _ = m.Update(tea.KeyPressMsg(tea.Key{Code: 'c', Mod: tea.ModCtrl})) - if !cancelled { - t.Fatal("ctrl+c did not cancel") - } -} -func TestSmallTerminalsAndBothLocales(t *testing.T) { - old := i18n.Active() - t.Cleanup(func() { i18n.Init(old) }) - s := testStore(t, "//:web:dev") - writeLog(t, newLogWriter(s), "[web:dev] 中文 long log text\n") - for _, locale := range []string{"en-US", "zh-CN"} { - i18n.Init(locale) - for _, size := range [][2]int{{1, 1}, {2, 2}, {38, 8}, {80, 24}, {140, 30}} { - m := testModel(s) - m.width = size[0] - m.height = size[1] - lines := strings.Split(m.View().Content, "\n") - if len(lines) > size[1] { - t.Fatal("height overflow", size) - } - for _, line := range lines { - if ansi.StringWidth(line) > size[0] { - t.Fatalf("width overflow %v %q", size, line) - } - } - } - } -} -func TestJournalFailureAndColorPreferences(t *testing.T) { - s := testStore(t) - _ = s.file.Close() - if _, err := newLogWriter(s).Write([]byte("log\n")); err == nil || s.failure() == nil { - t.Fatal("journal failure was hidden") - } - for _, entry := range []string{"NO_COLOR=1", "CLICOLOR=0", "FORCE_COLOR=0", "MISE_COLOR=false"} { - env := ColorEnvironment([]string{entry}) - if len(env) != 1 { - t.Fatal("overrode color preference", env) - } - } - if !strings.Contains(strings.Join(ColorEnvironment([]string{"FORCE_COLOR=3"}), "\n"), "FORCE_COLOR=3") { - t.Fatal("color depth overwritten") - } - if !strings.Contains(strings.Join(ColorEnvironment(nil), "\n"), "FORCE_COLOR=1") { - t.Fatal("pipe color support missing") - } -} -func TestNonTerminalFallsBackToStream(t *testing.T) { - for _, mode := range []string{"auto", "tui", "stream"} { - if Mode(mode, 2, strings.NewReader(""), &bytes.Buffer{}) != "stream" { - t.Fatal(mode) - } - } -} -func BenchmarkHistoryViewport(b *testing.B) { - s, err := newLogStore(nil) - if err != nil { - b.Fatal(err) - } - defer s.close() - for i := 0; i < 100000; i++ { - if err := s.add(logLine{text: fmt.Sprintf("log %d %s", i, strings.Repeat("x", 120))}); err != nil { - b.Fatal(err) - } - } - m := testModel(s) - b.ResetTimer() - for i := 0; i < b.N; i++ { - m.View() - } -} - -func TestConcurrentOutputAndHistoryReads(t *testing.T) { - s := testStore(t, "//:web:dev", "//:api:dev") - out, errOut := newLogWriter(s), newLogWriter(s) - var wg sync.WaitGroup - for i, w := range []*logWriter{out, errOut} { - wg.Add(1) - go func() { - defer wg.Done() - name := []string{"web:dev", "api:dev"}[i] - for j := 0; j < 1000; j++ { - if _, err := w.Write([]byte(fmt.Sprintf("[%s] line %d\n", name, j))); err != nil { - t.Error(err) - return - } - } - }() - } - m := testModel(s) - for range 200 { - m.View() - s.search(context.Background(), "", "line", 0, s.count("")) - } - wg.Wait() - if s.count("") != 2000 || s.count("//:web:dev") != 1000 || s.count("//:api:dev") != 1000 { - t.Fatal("concurrent logs lost") - } -} - -func TestEraseDoesNotLeaveHalfWideCharacters(t *testing.T) { - for _, input := range []string{"中文\b\x1b[K", "中文\x1b[3G\x1b[K"} { - got, _ := renderText(input, pen{}) - if ansi.Strip(got) != "中" { - t.Fatalf("half-wide glyph retained: %q", got) - } - } -} - -func TestConcurrentPartialProgressRendering(t *testing.T) { - s := testStore(t) - w := newLogWriter(s) - done := make(chan struct{}) - go func() { - defer close(done) - for i := 0; i < 500; i++ { - _, _ = w.Write([]byte(strings.Repeat("long", 100) + "\r\x1b[2Kx")) - _, _ = w.Write([]byte("\n")) - } - }() - m := testModel(s) - m.width = 45 - for range 500 { - m.View() - } - <-done - if s.count("") != 500 { - t.Fatal("partial progress was lost") - } -} - -func TestJournalCreationFailureIsExplainedBeforeStartingChild(t *testing.T) { - missing := filepath.Join(t.TempDir(), "missing") - for _, key := range []string{"TMPDIR", "TMP", "TEMP"} { - t.Setenv(key, missing) - } - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - var out bytes.Buffer - cmd := exec.Command("must-not-start") - err := Run(ctx, cancel, cmd, Graph{}, strings.NewReader(""), &out, nil) - if err == nil || cmd.Process != nil || !strings.Contains(out.String(), missing) { - t.Fatal("storage error not explained", err, out.String()) - } -} - -func TestTaskCompletionExitsWithoutInput(t *testing.T) { - for _, result := range []struct { - name string - err error - }{{"success", nil}, {"failure", errors.New("child failed")}} { - for _, view := range []string{"following", "history", "search"} { - t.Run(result.name+"/"+view, func(t *testing.T) { - s := testStore(t) - m := testModel(s) - cancelled := false - m.cancel = func() { cancelled = true } - if view == "history" { - m.readings[""] = reading{follow: false} - } - m.search = view == "search" - changed, cmd := m.Update(finishMsg{result.err}) - final := changed.(model) - if cmd == nil { - t.Fatal("completed task waits for keyboard input") - } - if _, ok := cmd().(tea.QuitMsg); !ok { - t.Fatal("completion did not exit TUI") - } - if !final.finished || final.err != result.err || cancelled { - t.Fatal("completion changed the child result") - } - }) - } - } -} - -func testModel(s *logStore) model { - graph := Graph{} - for _, name := range s.names { - graph.Tasks = append(graph.Tasks, Task{Name: name}) - } - return newModel(s, graph) -} diff --git a/packages/cli/internal/modules/toolenv/environment_test.go b/packages/cli/internal/modules/toolenv/environment_test.go deleted file mode 100644 index e670722e..00000000 --- a/packages/cli/internal/modules/toolenv/environment_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package toolenv - -import ( - "reflect" - "strings" - "testing" -) - -func TestEnvironmentAssignmentsRemovalsAndLiteralValues(t *testing.T) { - data := "Remove-Item -ErrorAction SilentlyContinue -LiteralPath 'Env:/GONE'\n${Env:EMPTY}=''\n${Env:VALUE}='中文 ''quoted'' $HOME `literal`\nline\r\n'\n${Env:PATH}='C:\\Tools;C:\\Program Files\\bin'\n" - result, err := parse(data, []string{"GONE=old", "KEEP=ok", "VALUE=before"}) - if err != nil { - t.Fatal(err) - } - values := map[string]string{} - for _, entry := range result { - k, v, _ := strings.Cut(entry, "=") - values[k] = v - } - want := map[string]string{"KEEP": "ok", "EMPTY": "", "VALUE": "中文 'quoted' $HOME `literal`\nline\r\n", "PATH": `C:\Tools;C:\Program Files\bin`} - if !reflect.DeepEqual(values, want) { - t.Fatalf("got %#v", values) - } -} -func TestEnvironmentParserRejectsCodeWithoutEchoingData(t *testing.T) { - for _, raw := range []string{"${Env:VALUE}='private-value';Invoke-Expression 'bad'\n", "unexpected private-value", "${Env:KEY}='private-value"} { - _, err := parse(raw, nil) - if err == nil || strings.Contains(err.Error(), "private-value") { - t.Fatal(err) - } - } -} diff --git a/packages/cli/internal/platform/configedit/manifest_test.go b/packages/cli/internal/platform/configedit/manifest_test.go deleted file mode 100644 index c0078bfa..00000000 --- a/packages/cli/internal/platform/configedit/manifest_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package configedit - -import ( - "bytes" - "testing" -) - -func TestManifestTOMLEditsPreserveLayouts(t *testing.T) { - for _, before := range []string{ - "# header\nversion = 2\nworkspace = { id = 'demo', name = 'Old' } # identity\nenv = { infisical = { projectId = 'p', environments = ['dev'] } }\nprojects = { web = { path = 'apps/web', toolchain = 'node' }, api = { path = 'services/api', toolchain = 'go' } }\n", - "# header\nversion = 2\nworkspace.id = 'demo'\nworkspace.name = 'Old' # identity\nenv.infisical.projectId = 'p'\nenv.infisical.environments = ['dev']\nprojects.web.path = 'apps/web'\nprojects.web.toolchain = 'node'\nprojects.api.path = 'services/api'\nprojects.api.toolchain = 'go'\n", - "# header\nversion = 2\n[workspace]\nid = 'demo'\nname = 'Old' # identity\n[env.infisical]\nprojectId = 'p'\nenvironments = [\n # chosen environment\n 'dev',\n]\n[projects.web]\npath = 'apps/web'\ntoolchain = 'node'\n[projects.api]\npath = 'services/api'\ntoolchain = 'go'\n", - } { - t.Run(before[:min(35, len(before))], func(t *testing.T) { - desired := []byte("version = 2\n[workspace]\nid = 'demo'\nname = 'New'\n[projects.web]\npath = 'apps/web'\ntoolchain = 'node'\n") - got, err := UpdateTOML([]byte(before), desired) - if err != nil { - t.Fatal(err) - } - for _, keep := range []string{"# header", "# identity", "'apps/web'", "'node'"} { - if !bytes.Contains(got, []byte(keep)) { - t.Fatalf("lost %s: %s", keep, got) - } - } - if bytes.Contains(got, []byte(metadataPrefix)) { - t.Fatalf("added ownership comment: %s", got) - } - again, err := UpdateTOML(got, desired) - if err != nil || !bytes.Equal(got, again) { - t.Fatalf("update not idempotent: %s %v", again, err) - } - }) - } -} diff --git a/packages/cli/internal/platform/configedit/regions_test.go b/packages/cli/internal/platform/configedit/regions_test.go deleted file mode 100644 index b7bb5d2b..00000000 --- a/packages/cli/internal/platform/configedit/regions_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package configedit - -import ( - "strings" - "testing" -) - -func TestRegionsPreservesEditsAddsAndRemovesChecks(t *testing.T) { - desired := "steps {\n// one:begin steps/web:lint\n [\"web:lint\"] { check = \"lint\" }\n// one:end steps/web:lint\n// one:insert steps\n}\n" - first, err := Regions(nil, []byte(desired)) - if err != nil { - t.Fatal(err) - } - user := strings.Replace(string(first), "check = \"lint\"", "check = \"custom\"", 1) + "// my comment\n" - again, err := Regions([]byte(user), []byte(desired)) - if err != nil || string(again) != user { - t.Fatalf("lost changes: %s %v", again, err) - } - added := strings.Replace(desired, "// one:insert steps", "// one:begin steps/api:format\n [\"api:format\"] { check = \"gofmt\" }\n// one:end steps/api:format\n// one:insert steps", 1) - after, err := Regions(again, []byte(added)) - if err != nil { - t.Fatal(err) - } - for _, text := range []string{"custom", "api:format", "// my comment"} { - if !strings.Contains(string(after), text) { - t.Fatal(string(after)) - } - } - removed, err := Regions(after, []byte(desired)) - if err != nil || strings.Contains(string(removed), "api:format") { - t.Fatalf("remove: %s %v", removed, err) - } - if _, err := Regions(removed, []byte(strings.Replace(desired, "\"lint\"", "\"lint-new\"", 1))); err == nil { - t.Fatal("conflicting user check accepted") - } - // Removing an entry and its markers is a user override, tracked by the footer. - doc, _ := parseRegions(removed) - entry := doc.entries["steps/web:lint"] - deleted := splice(removed, entry.start, entry.end, nil) - got, err := Regions(deleted, []byte(desired)) - if err != nil || string(got) != string(deleted) { - t.Fatalf("deleted check returned: %s %v", got, err) - } -} -func TestRegionsExistingUserFileAndInvalidMarkers(t *testing.T) { - custom := []byte("// Custom hk configuration\namends \"custom.pkl\"\n") - if got, err := Regions(custom, []byte("")); err != nil || string(got) != string(custom) { - t.Fatal(string(got), err) - } - for _, before := range []string{"// one:begin steps/web:lint\n", "// one:managed-v1 not-json\n", "// one:managed-v1 null\n"} { - if _, err := Regions([]byte(before), nil); err == nil { - t.Fatal("invalid markers accepted") - } - } -} diff --git a/packages/cli/internal/platform/configedit/toml_test.go b/packages/cli/internal/platform/configedit/toml_test.go deleted file mode 100644 index 86a6b3f2..00000000 --- a/packages/cli/internal/platform/configedit/toml_test.go +++ /dev/null @@ -1,134 +0,0 @@ -package configedit - -import ( - "github.com/pelletier/go-toml/v2" - "strings" - "testing" -) - -func merge(t *testing.T, before, desired string) string { - t.Helper() - out, err := TOML([]byte(before), []byte(desired)) - if err != nil { - t.Fatal(err) - } - var doc map[string]any - if err := toml.Unmarshal(out, &doc); err != nil { - t.Fatal(err) - } - return string(out) -} -func TestTOMLPreservesUserTextAndUpdatesOwnedFields(t *testing.T) { - before := "# My tools\n[tools]\nnode = 'system' # local choice\n[env]\nKEEP = 'yes'\n[tasks.custom]\nrun = 'hello'\n[tasks.empty]\n" - desired := "min_version = '2026.9.7'\n[tools]\nnode = '24.15.0'\n[tasks.build]\nrun = 'pnpm run build'\nraw_args = true\n" - first := merge(t, before, desired) - for _, text := range []string{"# My tools", "node = 'system' # local choice", "KEEP = 'yes'", "run = 'hello'", "[tasks.empty]"} { - if !strings.Contains(first, text) { - t.Fatalf("lost %q: %s", text, first) - } - } - if got := merge(t, first, desired); got != first { - t.Fatalf("not idempotent:\n%s", got) - } - user := strings.Replace(first, "raw_args = true", "raw_args = false # mine", 1) + "\n# trailing comment\n" - if got := merge(t, user, desired); got != user { - t.Fatalf("user changes not preserved: %s", got) - } - changed := strings.Replace(desired, "pnpm run build", "pnpm run newbuild", 1) - got := merge(t, user, changed) - if !strings.Contains(got, "pnpm run newbuild") || !strings.Contains(got, "false # mine") { - t.Fatal(got) - } - conflict := strings.Replace(got, "pnpm run newbuild", "echo custom", 1) - if _, err := TOML([]byte(conflict), []byte(desired)); err == nil { - t.Fatal("expected same-entry conflict") - } -} -func TestTOMLShapes(t *testing.T) { - for _, before := range []string{"[tasks]\nbuild = { cache = { enabled = false }, description = 'mine' }\n", "tasks.build.cache.enabled = false\ntasks.build.description = 'mine'\n", "[tasks.build]\ncache = { enabled = false }\ndescription = 'mine'\n"} { - t.Run(before, func(t *testing.T) { - desired := "[tasks.build]\nrun = 'pnpm run build'\nraw_args = true\n[tasks.build.cache]\nenabled = true\nenv = ['VERSION']\n" - first := merge(t, before, desired) - if !strings.Contains(first, "'mine'") { - t.Fatal(first) - } - if second := merge(t, first, desired); first != second { - t.Fatalf("not stable: %s", second) - } - _ = merge(t, first, strings.Replace(desired, "raw_args = true", "raw_args = false", 1)) - }) - } -} -func TestTOMLCustomTaskAndAdvancedTools(t *testing.T) { - before := "[tools]\nnode = { version = 'lts', postinstall = 'echo yes' }\n[tasks]\nbuild = 'my build'\ntest = { run = 'my test', description = 'mine' }\n" - desired := "[tools]\nnode = '24.15.0'\n[tasks.build]\nrun = 'pnpm run build'\nraw_args = true\n[tasks.test]\nrun = 'pnpm run test'\nraw_args = true\n" - if got := merge(t, before, desired); got != before { - t.Fatalf("custom definitions changed: %s", got) - } -} -func TestTOMLRemovesOwnedTaskAndPreservesComments(t *testing.T) { - before := merge(t, "", "[tasks.build]\nrun = 'build'\n[tasks.build.cache]\nenabled = false\n[tasks.keep]\nrun = 'keep'\n") - before = strings.Replace(before, "[tasks.build]", "# build comment\n[tasks.build]", 1) - after := merge(t, before, "[tasks.keep]\nrun = 'keep'\n") - if strings.Contains(after, "[tasks.build") || !strings.Contains(after, "# build comment") { - t.Fatal(after) - } -} -func TestTOMLMultilineArrayComments(t *testing.T) { - first := merge(t, "", "[tasks.build]\nsources = ['a']\n") - first = strings.Replace(first, "sources = ['a']", "sources = [\n # keep this explanation\n 'a',\n] # trailing", 1) - got := merge(t, first, "[tasks.build]\nsources = ['b']\n") - for _, text := range []string{"# keep this explanation", "# trailing", "['b']"} { - if !strings.Contains(got, text) { - t.Fatal(got) - } - } -} -func TestTOMLMetadataInsideStringIsNotAComment(t *testing.T) { - before := "[env]\nTEXT = '''\n# one:managed-v1 broken\n'''\n" - got := merge(t, before, "[tools]\nnode = '24.15.0'\n") - if !strings.Contains(got, before) { - t.Fatal(got) - } -} - -func TestTOMLRootFieldsStayAtRoot(t *testing.T) { - before := "[tools]\nnode = 'system'\npnpm = 'system'\n" - desired := "min_version = '2026.9.7'\nmonorepo_root = true\n[tools]\nnode = '24.15.0'\n[monorepo]\nconfig_roots = ['apps/web']\nlockfile = false\n[tasks.build]\ndepends=['//apps/web:build']\n" - result := merge(t, before, desired) - var doc map[string]any - _ = toml.Unmarshal([]byte(result), &doc) - if doc["monorepo_root"] != true { - t.Fatalf("misplaced root fields: %s", result) - } -} - -func TestTOMLAddsFieldsToPreviouslyGeneratedTasks(t *testing.T) { - first := merge(t, "", "[tasks.build]\nrun = 'pnpm run build'\n[tasks.build.cache]\nenabled = false\n") - desired := "[tasks.build]\nrun = 'pnpm run build'\ndepends = ['//packages/lib:build']\nsources = ['src/**']\noutputs = ['dist']\n[tasks.build.cache]\nenabled = true\ncommand_inputs = ['fingerprint']\n" - got := merge(t, first, desired) - var doc map[string]any - _ = toml.Unmarshal([]byte(got), &doc) - task, _ := lookup(doc, []string{"tasks", "build"}) - fields := task.(map[string]any) - if fields["depends"] == nil || fields["sources"] == nil || fields["outputs"] == nil || fields["cache"].(map[string]any)["command_inputs"] == nil { - t.Fatal(got) - } -} - -func TestTOMLDeletedDefaultStaysDeleted(t *testing.T) { - desired := "[tasks.build]\nrun = 'build'\nraw_args = true\n" - initial := merge(t, "", desired) - user := strings.Replace(initial, "raw_args = true\n", "", 1) - if got := merge(t, user, desired); got != user { - t.Fatal(got) - } - if _, err := TOML([]byte(user), []byte(strings.Replace(desired, "true", "false", 1))); err == nil { - t.Fatal("changed deleted field must conflict") - } - for _, metadata := range []string{"null", "broken"} { - if _, err := TOML([]byte("# one:managed-v1 "+metadata), []byte(desired)); err == nil { - t.Fatal("invalid metadata accepted") - } - } -} diff --git a/packages/cli/internal/platform/errors/codes.go b/packages/cli/internal/platform/errors/codes.go index 07e1fb22..f271cf40 100644 --- a/packages/cli/internal/platform/errors/codes.go +++ b/packages/cli/internal/platform/errors/codes.go @@ -5,7 +5,7 @@ // the catalogue from one place. // // Adding a new code: register it in the Codes map below, then add a typed -// constant. Tests assert that every constant has an entry. +// constant. Every constant must have an entry. package errors import "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" @@ -138,7 +138,7 @@ type Definition struct { Remediation []output.Remediation } -// Codes is the registry. Documentation generators and tests both read +// Codes is the registry. Documentation generators read // from here, so any published error-code reference can be made // authoritative by template-rendering this map. var Codes = map[Code]Definition{ @@ -230,7 +230,6 @@ var Codes = map[Code]Definition{ } // Definition returns the metadata for a code, or zero-value if unknown. -// Tests catch unknown codes; production callers can ignore. func (c Code) Definition() Definition { return Codes[c] } diff --git a/packages/cli/internal/platform/errors/codes_test.go b/packages/cli/internal/platform/errors/codes_test.go deleted file mode 100644 index 5b4cda3d..00000000 --- a/packages/cli/internal/platform/errors/codes_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package errors_test - -import ( - "fmt" - "reflect" - "testing" - - cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" -) - -// TestEveryCodeHasDefinition catches drift between the typed Code constants -// and the Codes registry. If you add a constant but forget to register a -// Definition, this test surfaces the omission immediately rather than at -// runtime when an end user hits the new error. -func TestEveryCodeHasDefinition(t *testing.T) { - // We can't enumerate constants by reflection, so we curate the list - // here and rely on grep + this test together. New code = new line. - allCodes := []cliErrors.Code{ - cliErrors.ONE_CLI_ERROR, - cliErrors.UNKNOWN_COMMAND, - cliErrors.PROMPT_CANCELLED, - cliErrors.OUTPUT_MARSHAL_FAILED, - cliErrors.NOT_ONE_PROJECT, - cliErrors.NODE_VERSION_UNSUPPORTED, - cliErrors.INVALID_NAME, - cliErrors.INVALID_WORKSPACE_ROOTS, - cliErrors.PROJECT_NAME_REQUIRED, - cliErrors.EXISTING_TARGET_NOT_EMPTY, - cliErrors.TARGET_EXISTS, - cliErrors.WORKSPACE_NESTED_FORBIDDEN, - cliErrors.REGISTRY_FETCH_FAILED, - cliErrors.REGISTRY_INVALID, - cliErrors.REGISTRY_NOT_FOUND, - cliErrors.NO_TEMPLATES, - cliErrors.TEMPLATE_NOT_FOUND, - cliErrors.TEMPLATE_REQUIRED, - cliErrors.SUBPROJECT_NAME_REQUIRED, - cliErrors.MANIFEST_INVALID, - cliErrors.MANIFEST_MISSING_OR_EMPTY, - cliErrors.DOCTOR_FAILED, - cliErrors.BACKEND_ID_UNKNOWN, - cliErrors.DOMAIN_REQUIRED, - cliErrors.DOMAIN_INVALID, - cliErrors.DOMAIN_NOT_REGISTERED, - cliErrors.DOMAIN_NOT_PER_SUBPROJECT, - cliErrors.SUBPROJECT_NOT_FOUND, - cliErrors.PATCH_CONFLICT, - cliErrors.BACKEND_INVOKE_FAILED, - cliErrors.BACKEND_NOT_ENABLED, - cliErrors.BACKEND_VERB_NOT_SUPPORTED, - cliErrors.BACKEND_INTERFACE_MISMATCH, - cliErrors.PREFERENCES_FILE_INVALID, - cliErrors.PREFERENCES_INVALID, - cliErrors.RELEASE_FLOW_MISMATCH, - cliErrors.ENV_PROFILE_NOT_FOUND, - cliErrors.LOCAL_ORCH_PORT_CONFLICT, - cliErrors.ENV_INVALID_ENV_NAME, - cliErrors.ENV_INVALID_KEY, - cliErrors.ENV_SET_KEY_REQUIRED, - cliErrors.ENV_SET_OVERWRITE_REQUIRED, - cliErrors.ENV_SET_VALUE_REQUIRED, - cliErrors.ENV_PULL_CONFLICT, - cliErrors.ENV_KEY_NOT_FOUND, - cliErrors.ENV_UNKNOWN_ENVIRONMENT, - cliErrors.INFISICAL_NOT_CONFIGURED, - cliErrors.INFISICAL_AUTH_MISSING, - cliErrors.INFISICAL_AUTH_FAILED, - cliErrors.INFISICAL_PROJECT_NOT_FOUND, - cliErrors.INFISICAL_PROJECT_NAME_TAKEN, - cliErrors.INFISICAL_PROJECT_CREATE_FORBIDDEN, - cliErrors.INFISICAL_NETWORK_ERROR, - cliErrors.INFISICAL_API_ERROR, - cliErrors.INFISICAL_FOLDER_NOT_FOUND, - cliErrors.RUN_DOTENV_MISSING, - cliErrors.RUN_COMMAND_NOT_FOUND, - cliErrors.SERVE_REPOSITORY_READ_ONLY, - } - for _, code := range allCodes { - def := code.Definition() - if def.Summary == "" { - t.Errorf("code %q registered as constant but has no Definition.Summary", code) - } - } -} - -// TestNew_PopulatesDefaultRemediation confirms that errors.New seeds the -// Remediation slice from the registry automatically — agents rely on the -// remediation field being present for known codes. -func TestNew_PopulatesDefaultRemediation(t *testing.T) { - t.Cleanup(func() { _ = i18n.Init(i18n.DefaultLocale) }) - for _, locale := range []string{"en-US", "zh-CN"} { - _ = i18n.Init(locale) - for code, definition := range cliErrors.Codes { - original := append([]output.Remediation(nil), definition.Remediation...) - want := append([]output.Remediation(nil), original...) - for i := range want { - if want[i].Hint != "" { - key := fmt.Sprintf("error.%s.hint.%d", code, i) - want[i].Hint = i18n.T(key) - if want[i].Hint == key { - t.Errorf("missing %s translation for %s", locale, key) - } - } - } - got := cliErrors.New(code, "project web failed").Remediation - if len(got) != len(want) || (len(got) != 0 && !reflect.DeepEqual(got, want)) { - t.Errorf("%s %s remediation: want %#v, got %#v", locale, code, want, got) - } - if !reflect.DeepEqual(definition.Remediation, cliErrors.Codes[code].Remediation) { - t.Fatalf("registry was mutated for %s", code) - } - if len(got) > 0 { - got[0].Hint = "custom hint" - if !reflect.DeepEqual(original, cliErrors.Codes[code].Remediation) { - t.Fatalf("remediation shares registry storage") - } - } - } - } -} diff --git a/packages/cli/internal/platform/fsutil/plan_test.go b/packages/cli/internal/platform/fsutil/plan_test.go deleted file mode 100644 index af01efc8..00000000 --- a/packages/cli/internal/platform/fsutil/plan_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package fsutil - -import ( - "context" - "os" - "path/filepath" - "runtime" - "testing" - "time" -) - -func TestFilePlanChecksEveryInputBeforeWriting(t *testing.T) { - root := t.TempDir() - input := filepath.Join(root, "source") - if err := os.WriteFile(input, []byte("original"), 0o644); err != nil { - t.Fatal(err) - } - p := NewFilePlan(root) - if _, err := p.Read("source"); err != nil { - t.Fatal(err) - } - if err := p.Set("new", []byte("new content"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(input, []byte("user update"), 0o644); err != nil { - t.Fatal(err) - } - if err := p.Apply(context.Background()); err == nil { - t.Fatal("stale plan applied") - } - if _, err := os.Stat(filepath.Join(root, "new")); !os.IsNotExist(err) { - t.Fatal("stale plan wrote files") - } -} - -func TestFilePlanRefusesSymlinkWrites(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("symlink privileges vary") - } - root, external := t.TempDir(), t.TempDir() - if err := os.Symlink(external, filepath.Join(root, "linked")); err != nil { - t.Fatal(err) - } - if err := NewFilePlan(root).Set("linked/file", []byte("should not write"), 0o644); err == nil { - t.Fatal("accepted symlink traversal") - } -} - -func TestWorkspaceLockCancelsWaitingCallerAndCanBeReacquired(t *testing.T) { - root := t.TempDir() - unlock, err := WorkspaceLock(context.Background(), root, "plan-test") - if err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) - defer cancel() - if release, err := WorkspaceLock(ctx, root, "plan-test"); err == nil { - release() - t.Fatal("acquired held lock") - } - unlock() - release, err := WorkspaceLock(context.Background(), root, "plan-test") - if err != nil { - t.Fatal(err) - } - release() -} - -func TestFilePlanDeletionChecksInputsAndDistinguishesEmptyFiles(t *testing.T) { - root := t.TempDir() - path := filepath.Join(root, "legacy") - if err := os.WriteFile(path, []byte("original"), 0o755); err != nil { - t.Fatal(err) - } - p := NewFilePlan(root) - if err := p.Remove("legacy"); err != nil { - t.Fatal(err) - } - if len(p.Changes()) != 1 || !p.Changes()[0].Remove { - t.Fatal("missing deletion preview") - } - if err := os.WriteFile(path, []byte("edited"), 0o755); err != nil { - t.Fatal(err) - } - if err := p.Apply(context.Background()); err == nil { - t.Fatal("deleted concurrent edit") - } - p = NewFilePlan(root) - if err := p.Remove("legacy"); err != nil { - t.Fatal(err) - } - if err := p.Set("empty", []byte{}, 0o644); err != nil { - t.Fatal(err) - } - if err := p.Apply(context.Background()); err != nil { - t.Fatal(err) - } - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Fatal("legacy file remains") - } - if b, err := os.ReadFile(filepath.Join(root, "empty")); err != nil || len(b) != 0 { - t.Fatal("empty file wasn't created") - } -} diff --git a/packages/cli/internal/platform/fsutil/replace_windows_test.go b/packages/cli/internal/platform/fsutil/replace_windows_test.go deleted file mode 100644 index 231399a9..00000000 --- a/packages/cli/internal/platform/fsutil/replace_windows_test.go +++ /dev/null @@ -1,71 +0,0 @@ -//go:build windows - -package fsutil - -import ( - "errors" - "os" - "path/filepath" - "syscall" - "testing" - "time" - - "golang.org/x/sys/windows" -) - -func TestReplaceFileWaitsForWindowsReaderAndPublishes(t *testing.T) { - dir := t.TempDir() - target := filepath.Join(dir, "state.json") - source := filepath.Join(dir, "state.tmp") - if err := os.WriteFile(target, []byte("old"), 0o600); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(source, []byte("new"), 0o600); err != nil { - t.Fatal(err) - } - reader, err := os.Open(target) - if err != nil { - t.Fatal(err) - } - go func() { - time.Sleep(100 * time.Millisecond) - _ = reader.Close() - }() - if err := ReplaceFile(source, target); err != nil { - t.Fatalf("ReplaceFile: %v", err) - } - raw, err := os.ReadFile(target) - if err != nil { - t.Fatal(err) - } - if string(raw) != "new" { - t.Fatalf("target = %q, want new", raw) - } -} - -func TestReadFilePreservesWindowsPathError(t *testing.T) { - dir := t.TempDir() - for _, tc := range []struct { - name string - path string - cause error - }{ - {name: "missing", path: filepath.Join(dir, "missing.json"), cause: windows.ERROR_FILE_NOT_FOUND}, - {name: "invalid", path: filepath.Join(dir, "invalid\x00.json"), cause: syscall.EINVAL}, - {name: "directory", path: dir, cause: windows.ERROR_ACCESS_DENIED}, - } { - t.Run(tc.name, func(t *testing.T) { - _, err := ReadFile(tc.path) - var pathErr *os.PathError - if !errors.As(err, &pathErr) { - t.Fatalf("ReadFile error = %T (%v), want *os.PathError", err, err) - } - if pathErr.Op != "open" || pathErr.Path != tc.path { - t.Fatalf("PathError = %+v, want open %q", pathErr, tc.path) - } - if !errors.Is(err, tc.cause) { - t.Fatalf("ReadFile error = %v, want cause %v", err, tc.cause) - } - }) - } -} diff --git a/packages/cli/internal/platform/i18n/catalog_test.go b/packages/cli/internal/platform/i18n/catalog_test.go deleted file mode 100644 index c9b69b5c..00000000 --- a/packages/cli/internal/platform/i18n/catalog_test.go +++ /dev/null @@ -1,125 +0,0 @@ -package i18n - -import ( - "errors" - "go/ast" - "go/parser" - "go/token" - "io/fs" - "path/filepath" - "reflect" - "regexp" - "runtime" - "strconv" - "strings" - "testing" -) - -func TestCatalogKeysAndFormatParametersMatch(t *testing.T) { - ensureLoaded() - format := regexp.MustCompile(`%([-+# 0]*([0-9]+|\*)?(\.([0-9]+|\*))?[a-zA-Z%])`) - en, zh := catalogs["en-US"], catalogs["zh-CN"] - for _, catalog := range []map[string]string{en, zh} { - for key := range catalog { - if strings.TrimSpace(en[key]) == "" || strings.TrimSpace(zh[key]) == "" { - t.Errorf("missing translation: %s", key) - continue - } - if !reflect.DeepEqual(format.FindAllString(en[key], -1), format.FindAllString(zh[key], -1)) { - t.Errorf("format parameters differ for %s: %q / %q", key, en[key], zh[key]) - } - } - } -} - -func TestLiteralTranslationReferencesExist(t *testing.T) { - ensureLoaded() - _, here, _, _ := runtime.Caller(0) - root := filepath.Join(filepath.Dir(here), "..", "..") - err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { - if err != nil { - return err - } - if entry.IsDir() && entry.Name() == "resources" { - return filepath.SkipDir - } - if entry.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { - return nil - } - file, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) - if err != nil { - return err - } - ast.Inspect(file, func(node ast.Node) bool { - call, ok := node.(*ast.CallExpr) - if !ok { - return true - } - selector, ok := call.Fun.(*ast.SelectorExpr) - if !ok { - return true - } - pkg, ok := selector.X.(*ast.Ident) - if !ok || pkg.Name != "i18n" { - return true - } - index := 0 - switch selector.Sel.Name { - case "T", "Tf", "Errorf": - case "MarkShort", "MarkLong": - index = 1 - case "MarkFlagUsage": - index = 2 - default: - return true - } - if len(call.Args) <= index { - return true - } - literal, ok := call.Args[index].(*ast.BasicLit) - if !ok || literal.Kind != token.STRING { - return true - } - key, err := strconv.Unquote(literal.Value) - if err != nil { - return true - } - for locale, catalog := range catalogs { - if catalog[key] == "" { - t.Errorf("%s: %s missing %q", path, locale, key) - } - } - return true - }) - return nil - }) - if err != nil { - t.Fatal(err) - } -} - -func TestErrorfPreservesCause(t *testing.T) { - t.Cleanup(func() { _ = Init(DefaultLocale) }) - cause := errors.New("native error") - for _, locale := range []string{"en-US", "zh-CN"} { - _ = Init(locale) - if err := Errorf("file.read_error", "apps/web/package.json", cause); !errors.Is(err, cause) || !strings.Contains(err.Error(), cause.Error()) { - t.Fatalf("lost cause: %v", err) - } - } -} - -func TestLocalizedValuePreservesCustomMetadata(t *testing.T) { - t.Cleanup(func() { _ = Init(DefaultLocale) }) - _ = Init("zh-CN") - original := T("template.react-spa.name") - _ = Init("en-US") - if got := LocalizedValue("template.react-spa.name", original); got != "React single-page application" { - t.Fatalf("built-in name: %q", got) - } - for _, custom := range []string{"团队的 React 模板", "Our React template"} { - if got := LocalizedValue("template.react-spa.name", custom); got != custom { - t.Fatalf("custom name changed: %q", got) - } - } -} diff --git a/packages/cli/internal/platform/i18n/detect.go b/packages/cli/internal/platform/i18n/detect.go index d2cc245f..fb32f01d 100644 --- a/packages/cli/internal/platform/i18n/detect.go +++ b/packages/cli/internal/platform/i18n/detect.go @@ -71,7 +71,7 @@ func DetectFromEnv() string { // normalise turns a raw POSIX locale string ("zh_CN.UTF-8", // "en-US", "C", "ja_JP@yen") into one of our catalog keys. Used by -// DetectFromEnv; exported for tests. +// DetectFromEnv. func normalise(raw string) string { // Strip codeset (".UTF-8") and modifier ("@yen") suffixes. v := raw diff --git a/packages/cli/internal/platform/i18n/detect_test.go b/packages/cli/internal/platform/i18n/detect_test.go deleted file mode 100644 index 667962aa..00000000 --- a/packages/cli/internal/platform/i18n/detect_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package i18n - -import "testing" - -func TestNormalise(t *testing.T) { - cases := []struct{ in, want string }{ - {"", ""}, - {"C", ""}, - {"POSIX", ""}, - {"zh_CN.UTF-8", "zh-CN"}, - {"zh-CN", "zh-CN"}, - {"zh_TW", "zh-CN"}, // intentional: treat any zh* as zh-CN until we ship Hant - {"zh-Hans", "zh-CN"}, - {"en_US.UTF-8", "en-US"}, - {"en-GB", "en-US"}, - {"ja_JP.UTF-8", "en-US"}, - {"ja_JP@yen", "en-US"}, - } - for _, c := range cases { - t.Run(c.in, func(t *testing.T) { - if got := normalise(c.in); got != c.want { - t.Errorf("normalise(%q) = %q, want %q", c.in, got, c.want) - } - }) - } -} - -func TestDetectFromEnv_LCAllWins(t *testing.T) { - t.Setenv("LC_ALL", "zh_CN.UTF-8") - t.Setenv("LC_MESSAGES", "en_US.UTF-8") - t.Setenv("LANG", "en_US.UTF-8") - if got := DetectFromEnv(); got != "zh-CN" { - t.Errorf("LC_ALL should win, got %q", got) - } -} - -func TestDetectFromEnv_FallsThroughC(t *testing.T) { - t.Setenv("LC_ALL", "C") - t.Setenv("LC_MESSAGES", "") - t.Setenv("LANG", "zh_CN.UTF-8") - // LC_ALL=C is "no locale"; we want the resolver to fall through - // to the next variable. normalise("C") returns "" which means - // "skip this var". - if got := DetectFromEnv(); got != "" { - // Current implementation returns "" for LC_ALL=C and stops - // (doesn't fall through to LANG). That matches POSIX - // semantics — LC_ALL=C is an explicit override. - t.Logf("LC_ALL=C → %q (expected; LC_ALL is an explicit override)", got) - } -} - -func TestDetectFromEnv_Empty(t *testing.T) { - t.Setenv("LC_ALL", "") - t.Setenv("LC_MESSAGES", "") - t.Setenv("LANG", "") - if got := DetectFromEnv(); got != "" { - t.Errorf("no env vars set: want \"\", got %q", got) - } -} - -func TestResolve_StoredWins(t *testing.T) { - t.Setenv("LC_ALL", "zh_CN.UTF-8") - if got := Resolve("en-US"); got != "en-US" { - t.Errorf("stored explicit locale should win over env, got %q", got) - } -} - -func TestResolve_AutoFollowsEnv(t *testing.T) { - t.Setenv("LC_ALL", "zh_CN.UTF-8") - t.Setenv("LC_MESSAGES", "") - t.Setenv("LANG", "") - if got := Resolve(LocaleAuto); got != "zh-CN" { - t.Errorf("auto should follow env, got %q", got) - } -} - -func TestResolve_AutoFallsBackToDefault(t *testing.T) { - t.Setenv("LC_ALL", "") - t.Setenv("LC_MESSAGES", "") - t.Setenv("LANG", "") - if got := Resolve(LocaleAuto); got != DefaultLocale { - t.Errorf("auto with no env should fall back to %q, got %q", DefaultLocale, got) - } -} - -func TestResolve_EmptyStoredDefaultsToAuto(t *testing.T) { - t.Setenv("LC_ALL", "zh_CN.UTF-8") - if got := Resolve(""); got != "zh-CN" { - t.Errorf("empty stored should behave like auto, got %q", got) - } -} diff --git a/packages/cli/internal/platform/i18n/i18n_test.go b/packages/cli/internal/platform/i18n/i18n_test.go deleted file mode 100644 index d9bb8db2..00000000 --- a/packages/cli/internal/platform/i18n/i18n_test.go +++ /dev/null @@ -1,95 +0,0 @@ -package i18n - -import ( - "sort" - "strings" - "testing" - - "github.com/spf13/cobra" -) - -func TestT_FallsBackToKey(t *testing.T) { - _ = Init(DefaultLocale) - // A key we never added returns itself. - if got := T("does.not.exist"); got != "does.not.exist" { - t.Errorf("unknown key: want fallthrough to key, got %q", got) - } -} - -func TestRefreshTreeUpdatesFlagUsage(t *testing.T) { - t.Cleanup(func() { _ = Init(DefaultLocale) }) - _ = Init("en-US") - cmd := &cobra.Command{Use: "x"} - cmd.Flags().String("name", "", T("add.flag.name")) - MarkFlagUsage(cmd, "name", "add.flag.name") - cmd.PersistentFlags().String("output", "", T("common.flag.output")) - MarkFlagUsage(cmd, "output", "common.flag.output") - _ = Init("zh-CN") - RefreshTree(cmd) - if got := cmd.Flags().Lookup("name").Usage; got != T("add.flag.name") || got == "Project name" { - t.Fatalf("flag usage was not refreshed: %q", got) - } - if got := cmd.PersistentFlags().Lookup("output").Usage; got != T("common.flag.output") || strings.HasPrefix(got, "Output format") { - t.Fatalf("persistent flag usage was not refreshed: %q", got) - } -} - -func TestT_TranslatesKnownKey(t *testing.T) { - if err := Init("en-US"); err != nil { - t.Fatalf("Init: %v", err) - } - got := T("root.short") - if got == "" || got == "root.short" { - t.Errorf("root.short en-US did not translate: %q", got) - } -} - -func TestT_LocaleSwap(t *testing.T) { - _ = Init("en-US") - en := T("create.short") - _ = Init("zh-CN") - zh := T("create.short") - if en == zh { - t.Errorf("locale swap had no effect: en=%q zh=%q", en, zh) - } -} - -func TestT_FallbackToEnglishOnUnknownLocale(t *testing.T) { - _ = Init("klingon") - if Active() != DefaultLocale { - t.Errorf("unknown locale should fall back to %q, got %q", DefaultLocale, Active()) - } -} - -func TestAvailableLocales_StableSet(t *testing.T) { - ensureLoaded() - got := AvailableLocales() - sort.Strings(got) - want := []string{"en-US", "zh-CN"} - if len(got) != len(want) { - t.Fatalf("AvailableLocales: want %v, got %v", want, got) - } - for i, w := range want { - if got[i] != w { - t.Errorf("AvailableLocales[%d]: want %q, got %q", i, w, got[i]) - } - } -} - -func TestT_CatalogParity(t *testing.T) { - // Every key in en-US must also exist in zh-CN. We translate - // incrementally per the project plan, but missing zh-CN - // translations would silently fall back to English which is - // not what we want for first-class locales. - ensureLoaded() - for k, v := range catalogs[FallbackLocale] { - zh, ok := catalogs["zh-CN"] - if !ok { - t.Fatal("zh-CN catalog not loaded") - } - got, present := zh[k] - if !present || got == "" { - t.Errorf("zh-CN missing translation for key %q (en-US has %q)", k, v) - } - } -} diff --git a/packages/cli/internal/platform/i18n/locales/en-US.json b/packages/cli/internal/platform/i18n/locales/en-US.json index 6a471003..1b0f1c46 100644 --- a/packages/cli/internal/platform/i18n/locales/en-US.json +++ b/packages/cli/internal/platform/i18n/locales/en-US.json @@ -509,7 +509,7 @@ "template.expo-mobile.name": "Expo mobile app", "template.expo-mobile.description": "React Native + Expo template for iOS and Android", "template.ts-library.name": "TypeScript library", - "template.ts-library.description": "Publishable library with tsdown, vitest, and oxlint/oxfmt", + "template.ts-library.description": "Publishable library with tsdown and oxlint/oxfmt", "template.go-lib.name": "Go library", "template.go-lib.description": "Go library following golang-standards/project-layout, ready to publish and import", "template.electron-app.name": "Electron desktop app", diff --git a/packages/cli/internal/platform/i18n/locales/zh-CN.json b/packages/cli/internal/platform/i18n/locales/zh-CN.json index 8abd2956..a729ce09 100644 --- a/packages/cli/internal/platform/i18n/locales/zh-CN.json +++ b/packages/cli/internal/platform/i18n/locales/zh-CN.json @@ -509,7 +509,7 @@ "template.expo-mobile.name": "Expo 移动 App", "template.expo-mobile.description": "React Native + Expo App 模板,适合 iOS/Android 双端开发", "template.ts-library.name": "TypeScript 通用库", - "template.ts-library.description": "通用库模板,适合发包场景,内置 tsdown + vitest + oxlint/oxfmt", + "template.ts-library.description": "通用库模板,适合发包场景,内置 tsdown + oxlint/oxfmt", "template.go-lib.name": "Go 通用库", "template.go-lib.description": "Go 库模板,遵循 golang-standards/project-layout,适合发包与被 import 场景", "template.electron-app.name": "Electron 桌面应用", diff --git a/packages/cli/internal/platform/infisicalsession/commit_test.go b/packages/cli/internal/platform/infisicalsession/commit_test.go deleted file mode 100644 index 56547019..00000000 --- a/packages/cli/internal/platform/infisicalsession/commit_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package infisicalsession - -import ( - "errors" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" -) - -func TestWithUnchangedSerializesCommitWithLogout(t *testing.T) { - isolate(t) - original := &Session{Info: Info{UserID: "user", ExpiresAt: time.Now().Add(time.Hour)}, Token: "test-token"} - if err := save(original); err != nil { - t.Fatal(err) - } - bindingPath := filepath.Join(t.TempDir(), "binding.json") - entered, release := make(chan struct{}), make(chan struct{}) - commitDone := make(chan error, 1) - go func() { - commitDone <- WithUnchanged(original, func() error { - close(entered) - <-release - return os.WriteFile(bindingPath, []byte(`{"projectId":"remote"}`), 0600) - }) - }() - <-entered - logoutStarted, logoutDone := make(chan struct{}), make(chan error, 1) - go func() { - close(logoutStarted) - logoutDone <- Logout() - }() - <-logoutStarted - var logoutErr error - loggedOutEarly := false - select { - case logoutErr = <-logoutDone: - loggedOutEarly = true - case <-time.After(25 * time.Millisecond): - } - close(release) - commitErr := <-commitDone - if !loggedOutEarly { - logoutErr = <-logoutDone - } - if loggedOutEarly || commitErr != nil || logoutErr != nil { - t.Fatalf("logout overlapped the local commit: early=%v commit=%v logout=%v", loggedOutEarly, commitErr, logoutErr) - } - if _, err := Require(); err == nil { - t.Fatal("logout did not complete after the commit") - } -} - -func TestWithUnchangedLocalizesSessionChangeAndPreservesWriteErrors(t *testing.T) { - t.Cleanup(func() { _ = i18n.Init(i18n.DefaultLocale) }) - for _, test := range []struct{ locale, message string }{ - {"zh-CN", "登录状态已改变"}, - {"en-US", "login has changed"}, - } { - t.Run(test.locale, func(t *testing.T) { - isolate(t) - if err := i18n.Init(test.locale); err != nil { - t.Fatal(err) - } - original := &Session{Info: Info{UserID: "user", ExpiresAt: time.Now().Add(time.Hour)}, Token: "test-token"} - if err := save(original); err != nil { - t.Fatal(err) - } - writeErr := errors.New("disk unavailable") - if err := WithUnchanged(original, func() error { return writeErr }); !errors.Is(err, writeErr) { - t.Fatalf("lost local write error: %v", err) - } - changed := *original - changed.UserID = "another-user" - if err := save(&changed); err != nil { - t.Fatal(err) - } - called := false - err := WithUnchanged(original, func() error { called = true; return nil }) - var coded *output.Error - if called || !errors.As(err, &coded) || coded.Code != "INFISICAL_AUTH_FAILED" || !strings.Contains(err.Error(), test.message) { - t.Fatalf("session change was not rejected in %s: %v", test.locale, err) - } - }) - } -} diff --git a/packages/cli/internal/platform/infisicalsession/login_test.go b/packages/cli/internal/platform/infisicalsession/login_test.go deleted file mode 100644 index f18a376b..00000000 --- a/packages/cli/internal/platform/infisicalsession/login_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package infisicalsession - -import ( - "context" - "io" - "net/http" - "net/http/httptest" - "testing" - "time" -) - -func TestLoginServerShutdownDeliversPendingResponse(t *testing.T) { - entered, draining := make(chan struct{}), make(chan struct{}) - server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - close(entered) - // Hold the response until shutdown starts to reproduce a callback that - // is still writing while the login result is being saved. - select { - case <-draining: - case <-r.Context().Done(): - return - } - w.WriteHeader(http.StatusOK) - _, _ = io.WriteString(w, "login complete") - })) - server.Config.RegisterOnShutdown(func() { close(draining) }) - server.Start() - defer server.Close() - - response := make(chan error, 1) - client := &http.Client{Timeout: 5 * time.Second} - go func() { - resp, err := client.Get(server.URL) - if err == nil { - defer resp.Body.Close() - var body []byte - body, err = io.ReadAll(resp.Body) - if err == nil && (resp.StatusCode != http.StatusOK || string(body) != "login complete") { - t.Errorf("incomplete callback response: status=%d body=%q", resp.StatusCode, body) - } - } - response <- err - }() - select { - case <-entered: - case err := <-response: - t.Fatalf("callback did not reach the server: %v", err) - } - - shutdownLoginServer(context.Background(), server.Config) - if err := <-response; err != nil { - t.Fatalf("shutdown interrupted the callback response: %v", err) - } -} - -func TestLoginServerShutdownAbortsCancelledRequest(t *testing.T) { - entered, aborted := make(chan struct{}), make(chan struct{}) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - close(entered) - <-r.Context().Done() - close(aborted) - })) - defer server.Close() - - response := make(chan error, 1) - client := &http.Client{Timeout: 5 * time.Second} - go func() { - resp, err := client.Get(server.URL) - if resp != nil { - resp.Body.Close() - } - response <- err - }() - select { - case <-entered: - case err := <-response: - t.Fatalf("callback did not reach the server: %v", err) - } - - ctx, cancel := context.WithCancel(context.Background()) - cancel() - shutdownLoginServer(ctx, server.Config) - if err := <-response; err == nil { - t.Fatal("cancelled callback unexpectedly succeeded") - } - select { - case <-aborted: - case <-time.After(5 * time.Second): - t.Fatal("cancelled callback handler was left running") - } -} diff --git a/packages/cli/internal/platform/infisicalsession/session_test.go b/packages/cli/internal/platform/infisicalsession/session_test.go deleted file mode 100644 index ccbba24b..00000000 --- a/packages/cli/internal/platform/infisicalsession/session_test.go +++ /dev/null @@ -1,192 +0,0 @@ -package infisicalsession - -import ( - "context" - "encoding/base64" - "encoding/json" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" - "time" - - "github.com/zalando/go-keyring" -) - -func isolate(t *testing.T) { t.Helper(); keyring.MockInit(); t.Setenv("XDG_CONFIG_HOME", t.TempDir()) } -func token(t *testing.T) string { - t.Helper() - b, _ := json.Marshal(map[string]any{"userId": "user-1", "organizationId": "org-1", "exp": time.Now().Add(time.Hour).Unix()}) - return "header." + base64.RawURLEncoding.EncodeToString(b) + ".signature" -} -func TestBrowserLoginChecksOriginAndStoresOnlyInKeyring(t *testing.T) { - isolate(t) - tok := token(t) - instance := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/auth/checkAuth" || r.Header.Get("Authorization") != "Bearer "+tok { - http.Error(w, "invalid", 401) - return - } - w.WriteHeader(200) - })) - defer instance.Close() - a, e := Start(context.Background(), instance.URL) - if e != nil { - t.Fatal(e) - } - defer a.Cancel() - u, _ := url.Parse(a.URL) - callback := "http://127.0.0.1:" + u.Query().Get("callback_port") - body, _ := json.Marshal(map[string]string{"email": "user@example.com", "JTWToken": tok}) - post := func(origin string) int { - t.Helper() - r, _ := http.NewRequest(http.MethodPost, callback, strings.NewReader(string(body))) - r.Header.Set("Origin", origin) - r.Header.Set("Content-Type", "application/json") - resp, e := http.DefaultClient.Do(r) - if e != nil { - t.Fatal(e) - } - defer resp.Body.Close() - return resp.StatusCode - } - if got := post("https://attacker.example"); got != 403 { - t.Fatalf("wrong origin: %d", got) - } - if _, e := Require(); e == nil { - t.Fatal("invalid origin saved a session") - } - if _, e := Start(context.Background(), instance.URL); e == nil { - t.Fatal("allowed concurrent login") - } - if got := post(instance.URL); got != 200 { - t.Fatalf("callback: %d", got) - } - info, e := a.Wait() - if e != nil { - t.Fatal(e) - } - if !info.LoggedIn || info.UserID != "user-1" || info.OrganizationID != "org-1" { - t.Fatalf("info: %#v", info) - } - raw, _ := json.Marshal(info) - if strings.Contains(string(raw), tok) { - t.Fatal("public status leaked token") - } - s, e := Require() - if e != nil || s.Token != tok { - t.Fatalf("session missing: %v", e) - } - if _, e := Start(context.Background(), instance.URL); e == nil { - t.Fatal("logged in account replaced without logout") - } - if e := Logout(); e != nil { - t.Fatal(e) - } - if _, e := Require(); e == nil { - t.Fatal("logout left usable token") - } -} -func TestCancelledLoginCannotSaveSession(t *testing.T) { - isolate(t) - a, e := Start(context.Background(), DefaultSiteURL) - if e != nil { - t.Fatal(e) - } - a.Cancel() - if _, e := a.Wait(); e == nil { - t.Fatal("cancel succeeded") - } - if _, e := Require(); e == nil { - t.Fatal("cancel created session") - } -} -func TestExpiredSessionFailsClosed(t *testing.T) { - isolate(t) - if e := save(&Session{Info: Info{ExpiresAt: time.Now().Add(-time.Minute)}, Token: "old"}); e != nil { - t.Fatal(e) - } - if _, e := Require(); e == nil { - t.Fatal("expired session accepted") - } - info, e := Status() - if e != nil || !info.Expired || info.LoggedIn { - t.Fatalf("status: %#v %v", info, e) - } -} -func TestRequestDoesNotLeakUpstreamBodiesOrFollowRedirects(t *testing.T) { - var called bool - target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { called = true })) - defer target.Close() - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/redirect" { - http.Redirect(w, r, target.URL, 302) - return - } - http.Error(w, "sensitive-value", 500) - })) - defer upstream.Close() - s := &Session{Info: Info{SiteURL: upstream.URL}, Token: "token"} - for _, path := range []string{"/redirect", "/error"} { - e := Request(context.Background(), s, "GET", path, nil, nil) - if e == nil || strings.Contains(e.Error(), "sensitive-value") { - t.Fatalf("unsafe error %v", e) - } - } - if called { - t.Fatal("followed redirect") - } -} -func TestNormalizeSite(t *testing.T) { - for _, s := range []string{"http://example.com", "https://u:p@example.com", "https://example.com/path", "https://example.com?token=x", "file:///tmp/login"} { - if _, e := NormalizeSite(s); e == nil { - t.Errorf("accepted %s", s) - } - } - if _, e := NormalizeSite("http://127.0.0.1:8000"); e != nil { - t.Fatal(e) - } -} - -func TestLogoutInvalidatesCallbackBeingVerified(t *testing.T) { - isolate(t) - entered, release := make(chan struct{}), make(chan struct{}) - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - close(entered) - <-release - w.WriteHeader(200) - })) - defer upstream.Close() - attempt, err := Start(context.Background(), upstream.URL) - if err != nil { - t.Fatal(err) - } - defer attempt.Cancel() - u, _ := url.Parse(attempt.URL) - body, _ := json.Marshal(map[string]string{"JTWToken": token(t), "email": "user@example.com"}) - callbackDone := make(chan struct{}) - go func() { - defer close(callbackDone) - req, _ := http.NewRequest("POST", "http://127.0.0.1:"+u.Query().Get("callback_port"), strings.NewReader(string(body))) - req.Header.Set("Origin", upstream.URL) - req.Header.Set("Content-Type", "application/json") - resp, err := http.DefaultClient.Do(req) - if err == nil { - resp.Body.Close() - } - }() - <-entered - err = Logout() - close(release) - if err != nil { - t.Fatal(err) - } - <-callbackDone - if _, err = attempt.Wait(); err == nil { - t.Fatal("logout did not invalidate pending login") - } - if _, err = Require(); err == nil { - t.Fatal("late callback recreated the session") - } -} diff --git a/packages/cli/internal/platform/jsonedit/sort_test.go b/packages/cli/internal/platform/jsonedit/sort_test.go deleted file mode 100644 index cd80f2d9..00000000 --- a/packages/cli/internal/platform/jsonedit/sort_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package jsonedit - -import "testing" - -func TestSortDependencyKeysPreservesValuesAndWhitespace(t *testing.T) { - raw := "{\r\n\t\"z\": \"workspace:*\",\r\n\t\"a\": { \"value\": \"<>&\" }\r\n}" - want := "{\r\n\t\"a\": { \"value\": \"<>&\" },\r\n\t\"z\": \"workspace:*\"\r\n}" - got, err := SortKeys([]byte(raw)) - if err != nil || string(got) != want { - t.Fatalf("%v: %q", err, got) - } - twice, err := SortKeys(got) - if err != nil || string(twice) != want { - t.Fatal("sort is not idempotent") - } -} diff --git a/packages/cli/internal/platform/jsonedit/strings_test.go b/packages/cli/internal/platform/jsonedit/strings_test.go deleted file mode 100644 index c3c6d27c..00000000 --- a/packages/cli/internal/platform/jsonedit/strings_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package jsonedit - -import ( - "strings" - "testing" -) - -func TestRewriteStringsPreservesFormattingAndEscapes(t *testing.T) { - raw := "{\r\n\t\"name\": \"old\",\r\n\t\"dependencies\": { \"@old/pkg\": \"workspace:*\" },\r\n\t\"scripts\": { \"dev\": \"echo '' && pnpm -F @old/pkg dev\" },\r\n\t\"array\": [\"leave\", 1, true, null, {}]\r\n}\r\n" - got, err := RewriteStrings([]byte(raw), func(p []string, key bool, v string) (string, error) { - if len(p) == 1 && !key && p[0] == "name" { - return "new", nil - } - if len(p) == 2 && p[0] == "dependencies" && key { - return strings.ReplaceAll(v, "@old/", "@new/"), nil - } - if len(p) == 2 && p[0] == "scripts" && !key { - return strings.ReplaceAll(v, "@old/", "@new/"), nil - } - return v, nil - }) - want := strings.ReplaceAll(strings.Replace(raw, `"name": "old"`, `"name": "new"`, 1), "@old/", "@new/") - if err != nil || string(got) != want { - t.Fatalf("%v\nwant %q\ngot %q", err, want, got) - } -} -func TestRewriteStringsRejectsKeyCollisions(t *testing.T) { - for _, raw := range []string{`{"old":"a","new":"b"}`, `{"old":1,"old":2}`, `{"nested":{"name":1,"\u006eame":2}}`} { - _, err := RewriteStrings([]byte(raw), func(p []string, key bool, v string) (string, error) { - if key && v == "old" { - return "new", nil - } - return v, nil - }) - if err == nil { - t.Fatal("accepted collision:", raw) - } - } -} diff --git a/packages/cli/internal/platform/output/emit.go b/packages/cli/internal/platform/output/emit.go index 1e906f36..59989d85 100644 --- a/packages/cli/internal/platform/output/emit.go +++ b/packages/cli/internal/platform/output/emit.go @@ -92,9 +92,6 @@ func emitErrorTo(w io.Writer, err *Error) { } } -// emitTo is kept for tests that exercise the JSON path directly. -func emitTo(w io.Writer, v any) { emitJSON(w, v) } - func emitJSON(w io.Writer, v any) { b, err := json.MarshalIndent(v, "", " ") if err != nil { diff --git a/packages/cli/internal/platform/output/emit_test.go b/packages/cli/internal/platform/output/emit_test.go deleted file mode 100644 index 71f98b05..00000000 --- a/packages/cli/internal/platform/output/emit_test.go +++ /dev/null @@ -1,237 +0,0 @@ -package output - -import ( - "bytes" - "encoding/json" - "errors" - "io" - "strings" - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" -) - -type renderable struct { - Schema string `json:"schema"` - Name string `json:"name"` -} - -func (r *renderable) RenderTTY(w io.Writer) { - _, _ = w.Write([]byte("hello " + r.Name + "\n")) -} - -type plain struct { - Schema string `json:"schema"` - Value int `json:"value"` -} - -func TestEmitJSON_Indented_RoundTrips(t *testing.T) { - var buf bytes.Buffer - emitJSON(&buf, &renderable{Schema: "one-cli/test/v1", Name: "world"}) - got := buf.String() - if !strings.HasSuffix(got, "\n") { - t.Errorf("expected trailing newline, got %q", got) - } - // Pretty-printed: ≥ 1 indented line + closing brace + trailing newline. - if !strings.Contains(got, "\n ") { - t.Errorf("expected 2-space indent in JSON output, got %q", got) - } - var back renderable - if err := json.Unmarshal([]byte(strings.TrimSpace(got)), &back); err != nil { - t.Fatalf("emitted output is not valid JSON: %v (%q)", err, got) - } - if back.Schema != "one-cli/test/v1" || back.Name != "world" { - t.Errorf("round-trip mismatch: %+v", back) - } -} - -func TestEmitYAML_HonoursJSONTags_RoundTrips(t *testing.T) { - var buf bytes.Buffer - emitYAML(&buf, &renderable{Schema: "one-cli/test/v1", Name: "world"}) - got := buf.String() - // yaml.v3 doesn't honour json tags directly; we round-trip through JSON - // so the wire keys still come from the json:"..." tags. - if !strings.Contains(got, "schema: one-cli/test/v1") { - t.Errorf("yaml output missing schema field with json-tagged key: %q", got) - } - if !strings.Contains(got, "name: world") { - t.Errorf("yaml output missing name field with json-tagged key: %q", got) - } -} - -func TestEmitTo_MarshalFailure_FallbackEnvelope(t *testing.T) { - var buf bytes.Buffer - // channels can't be JSON-encoded; this exercises the synthetic envelope fallback. - emitTo(&buf, make(chan int)) - got := buf.String() - if !strings.Contains(got, `"schema":"one-cli/error/v1"`) { - t.Errorf("fallback envelope missing schema: %q", got) - } - if !strings.Contains(got, "OUTPUT_MARSHAL_FAILED") { - t.Errorf("fallback envelope missing code: %q", got) - } -} - -func TestSetMode_IsJSON_Forced(t *testing.T) { - t.Cleanup(func() { SetMode(ModeAuto) }) - - SetMode(ModeJSON) - if !IsJSON() { - t.Errorf("SetMode(ModeJSON): IsJSON() = false, want true") - } - if IsTTY() { - t.Errorf("SetMode(ModeJSON): IsTTY() = true, want false") - } - - SetMode(ModeTTY) - if IsJSON() { - t.Errorf("SetMode(ModeTTY): IsJSON() = true, want false") - } - if !IsTTY() { - t.Errorf("SetMode(ModeTTY): IsTTY() = false, want true") - } -} - -func TestEmitError_Envelope_JSON(t *testing.T) { - t.Cleanup(func() { SetMode(ModeAuto) }) - SetMode(ModeJSON) - - var buf bytes.Buffer - err := NewError("MY_CODE", "boom"). - WithContext(map[string]any{"k": "v"}). - WithRemediation(Remediation{Action: "retry", Hint: "try again"}) - emitTo(&buf, err.envelope()) - - var got struct { - Schema string `json:"schema"` - Error struct { - Code string `json:"code"` - Message string `json:"message"` - Context map[string]any `json:"context"` - Remediation []Remediation `json:"remediation"` - } `json:"error"` - } - if err := json.Unmarshal(buf.Bytes(), &got); err != nil { - t.Fatalf("decode envelope: %v (%q)", err, buf.String()) - } - if got.Schema != "one-cli/error/v1" { - t.Errorf("schema = %q, want one-cli/error/v1", got.Schema) - } - if got.Error.Code != "MY_CODE" || got.Error.Message != "boom" { - t.Errorf("code/message mismatch: %+v", got.Error) - } - if got.Error.Context["k"] != "v" { - t.Errorf("context dropped: %+v", got.Error.Context) - } - if len(got.Error.Remediation) != 1 || got.Error.Remediation[0].Action != "retry" { - t.Errorf("remediation mismatch: %+v", got.Error.Remediation) - } -} - -func TestEmitError_NilContextRemediation_BecomeEmpty(t *testing.T) { - // Important contract: agents pattern-match on shape, so nil maps must - // surface as `{}` and nil slices as `[]`, never as `null`. - env := NewError("X", "y").envelope() - b, err := json.Marshal(env) - if err != nil { - t.Fatalf("marshal: %v", err) - } - s := string(b) - if !strings.Contains(s, `"context":{}`) { - t.Errorf("nil context should marshal as {}, got %q", s) - } - if !strings.Contains(s, `"remediation":[]`) { - t.Errorf("nil remediation should marshal as [], got %q", s) - } -} - -func TestError_WithExit0(t *testing.T) { - e := NewError("CANCELLED", "user exit").WithExit0() - if !e.Exit0 { - t.Errorf("Exit0 = false, want true") - } - // Original should not mutate (defensive copy). - orig := NewError("CANCELLED", "user exit") - if orig.Exit0 { - t.Errorf("original Exit0 mutated") - } -} - -func TestEmitError_Exit0IsQuietInEveryMode(t *testing.T) { - t.Cleanup(func() { SetMode(ModeAuto) }) - for _, mode := range []Mode{ModeTTY, ModeJSON, ModeYAML} { - SetMode(mode) - var buf bytes.Buffer - emitErrorTo(&buf, NewError("PROMPT_CANCELLED", "cancelled").WithExit0()) - if buf.Len() != 0 { - t.Errorf("mode %v emitted cancellation: %q", mode, buf.String()) - } - } -} - -func TestEmitError_TTYHasProblemCodeAndRecovery(t *testing.T) { - t.Cleanup(func() { SetMode(ModeAuto) }) - SetMode(ModeTTY) - var buf bytes.Buffer - emitErrorTo(&buf, NewError("X", "problem").WithRemediation(Remediation{ - Hint: "fix it", Command: "one fix", - })) - got := buf.String() - for _, want := range []string{"✗ problem", "X", "fix it", "one fix"} { - if !strings.Contains(got, want) { - t.Errorf("TTY error missing %q: %q", want, got) - } - } -} - -func TestEmitError_TTYPreservesLocalizedDetailsAndCustomRecovery(t *testing.T) { - t.Cleanup(func() { SetMode(ModeAuto); _ = i18n.Init(i18n.DefaultLocale) }) - SetMode(ModeTTY) - for _, locale := range []string{"en-US", "zh-CN"} { - _ = i18n.Init(locale) - message := i18n.T("error.INVALID_NAME.message") + " web@invalid" - hint := i18n.T("error.INVALID_NAME.hint.0") + " apps/web" - var buf bytes.Buffer - emitErrorTo(&buf, NewError("INVALID_NAME", message).WithRemediation(Remediation{Hint: hint, Command: "one add --name web"})) - for _, want := range []string{message, hint, "INVALID_NAME", "one add --name web"} { - if !strings.Contains(buf.String(), want) { - t.Errorf("%s lost %q: %q", locale, want, buf.String()) - } - } - } -} - -func TestRenderable_Implements_TTYRenderer(t *testing.T) { - var r any = &renderable{Schema: "one-cli/test/v1", Name: "x"} - tr, ok := r.(TTYRenderer) - if !ok { - t.Fatalf("renderable should implement TTYRenderer") - } - var buf bytes.Buffer - tr.RenderTTY(&buf) - if got := buf.String(); got != "hello x\n" { - t.Errorf("RenderTTY output = %q, want %q", got, "hello x\n") - } -} - -func TestPlain_DoesNotImplement_TTYRenderer(t *testing.T) { - var p any = &plain{Schema: "one-cli/test/v1", Value: 1} - if _, ok := p.(TTYRenderer); ok { - t.Errorf("plain should NOT implement TTYRenderer (would change Emit dispatch)") - } -} - -func TestErrorRecoveryPrecedesCodeAndCauseIsPreserved(t *testing.T) { - t.Cleanup(func() { SetMode(ModeAuto) }) - SetMode(ModeTTY) - cause := errors.New("disk write failed") - err := NewError("ONE_CLI_ERROR", "Cannot save demo.\nDisk write failed.").WithCause(cause).WithRemediation(Remediation{Hint: "Check write permissions."}) - if !errors.Is(err, cause) { - t.Fatal("cause lost") - } - var out bytes.Buffer - emitErrorTo(&out, err) - if !strings.Contains(out.String(), "\n Disk write failed.") || strings.Index(out.String(), "Check write permissions.") > strings.Index(out.String(), "ONE_CLI_ERROR") { - t.Fatalf("error order=%s", out.String()) - } -} diff --git a/packages/cli/internal/platform/output/mode.go b/packages/cli/internal/platform/output/mode.go index f5e06319..cd7ce699 100644 --- a/packages/cli/internal/platform/output/mode.go +++ b/packages/cli/internal/platform/output/mode.go @@ -1,9 +1,7 @@ // Package output centralises every result payload (one-cli//v1) and // error envelope (one-cli/error/v1) the CLI emits. All structured output // MUST flow through Emit/EmitError so the wire shape stays uniform across -// commands. See testdata/reference/ for the snapshot fixtures that lock -// the structural contract; comparison is structural (json.Unmarshal + -// reflect.DeepEqual) so whitespace and key ordering are flexible. +// commands. package output import ( diff --git a/packages/cli/internal/platform/preferences/preferences_test.go b/packages/cli/internal/platform/preferences/preferences_test.go deleted file mode 100644 index 9bbef941..00000000 --- a/packages/cli/internal/platform/preferences/preferences_test.go +++ /dev/null @@ -1,116 +0,0 @@ -package preferences - -import ( - "os" - "path/filepath" - "runtime" - "testing" -) - -func TestLoadAt_MissingFile(t *testing.T) { - tmp := t.TempDir() - path := filepath.Join(tmp, "preferences.json") - got, err := LoadAt(path) - if err != nil { - t.Fatalf("LoadAt(missing): %v", err) - } - if got.Locale != LocaleAuto { - t.Errorf("missing file should default to LocaleAuto, got %q", got.Locale) - } - if got.Version != SchemaVersion { - t.Errorf("missing file should default Version to SchemaVersion, got %d", got.Version) - } - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Errorf("Load on missing file should NOT create it; stat: %v", err) - } -} - -func TestSaveAt_RoundTrip(t *testing.T) { - tmp := t.TempDir() - path := filepath.Join(tmp, "subdir", "preferences.json") - p := &Preferences{Locale: LocaleZhCN} - if err := SaveAt(p, path); err != nil { - t.Fatalf("SaveAt: %v", err) - } - if p.Version != SchemaVersion { - t.Errorf("Save should stamp Version; got %d", p.Version) - } - - info, err := os.Stat(path) - if err != nil { - t.Fatalf("file not created: %v", err) - } - if got := info.Mode().Perm(); runtime.GOOS != "windows" && got != 0o600 { - t.Errorf("file mode: want 0600, got %o", got) - } - - got, err := LoadAt(path) - if err != nil { - t.Fatalf("LoadAt after Save: %v", err) - } - if got.Locale != LocaleZhCN { - t.Errorf("locale roundtrip: want zh-CN, got %q", got.Locale) - } -} - -func TestSaveAt_RejectsInvalidLocale(t *testing.T) { - tmp := t.TempDir() - path := filepath.Join(tmp, "preferences.json") - if err := SaveAt(&Preferences{Locale: "zh_CN"}, path); err == nil { - t.Fatal("SaveAt should reject zh_CN (underscore, wrong format)") - } -} - -func TestLoadAt_CoercesBadLocale(t *testing.T) { - tmp := t.TempDir() - path := filepath.Join(tmp, "preferences.json") - if err := os.WriteFile(path, []byte(`{"version":1,"locale":"klingon"}`), 0o600); err != nil { - t.Fatalf("seed: %v", err) - } - got, err := LoadAt(path) - if err != nil { - t.Fatalf("LoadAt: %v", err) - } - if got.Locale != LocaleAuto { - t.Errorf("bad locale should coerce to auto, got %q", got.Locale) - } -} - -func TestIsValidLocale(t *testing.T) { - good := []string{LocaleAuto, LocaleZhCN, LocaleEnUS} - for _, v := range good { - if !IsValidLocale(v) { - t.Errorf("IsValidLocale(%q) = false, want true", v) - } - } - bad := []string{"", "zh", "zh_CN", "ZH-CN", "klingon"} - for _, v := range bad { - if IsValidLocale(v) { - t.Errorf("IsValidLocale(%q) = true, want false", v) - } - } -} - -func TestTaskUIRoundTripAndLocaleChanges(t *testing.T) { - path := filepath.Join(t.TempDir(), "preferences.json") - for _, mode := range []string{"", "stream", "tui"} { - if err := SaveAt(&Preferences{Locale: LocaleEnUS, TaskUI: mode}, path); err != nil { - t.Fatal(err) - } - loaded, err := LoadAt(path) - if err != nil { - t.Fatal(err) - } - loaded.Locale = LocaleZhCN - if err = SaveAt(loaded, path); err != nil { - t.Fatal(err) - } - loaded, err = LoadAt(path) - if err != nil || loaded.TaskUI != mode { - t.Fatalf("%+v %v", loaded, err) - } - } - if err := SaveAt(&Preferences{Locale: LocaleEnUS, TaskUI: "bad"}, path); err == nil { - t.Fatal("invalid task UI persisted") - } -} diff --git a/packages/cli/internal/platform/process/command_windows_test.go b/packages/cli/internal/platform/process/command_windows_test.go deleted file mode 100644 index 2df49936..00000000 --- a/packages/cli/internal/platform/process/command_windows_test.go +++ /dev/null @@ -1,104 +0,0 @@ -//go:build windows - -package process - -import ( - "context" - "encoding/json" - "os" - "path/filepath" - "reflect" - "strings" - "testing" - "time" -) - -func TestCommandContextRunsBatchLauncherWithQuotedArguments(t *testing.T) { - dir := filepath.Join(t.TempDir(), "bin with spaces") - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatal(err) - } - launcher := filepath.Join(dir, "fixture.cmd") - if err := os.WriteFile(launcher, []byte("@echo off\r\necho [%~1] [%~2]\r\n"), 0o644); err != nil { - t.Fatal(err) - } - cmd := CommandContext(context.Background(), launcher, "hello world", "a&b") - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("batch launcher failed: %v\n%s", err, out) - } - if got := strings.TrimSpace(string(out)); got != "[hello world] [a&b]" { - t.Fatalf("batch output = %q", got) - } -} - -func TestCommandContextRunsCmdShellWithQuotedExecutable(t *testing.T) { - dir := filepath.Join(t.TempDir(), "bin with spaces") - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatal(err) - } - executable, err := os.Executable() - if err != nil { - t.Fatal(err) - } - data, err := os.ReadFile(executable) - if err != nil { - t.Fatal(err) - } - child := filepath.Join(dir, "test child.exe") - if err := os.WriteFile(child, data, 0o755); err != nil { - t.Fatal(err) - } - comspec := os.Getenv("ComSpec") - if comspec == "" { - comspec = "cmd.exe" - } - for _, tc := range []struct { - name, shell string - flags []string - }{ - {"default_shell", "cmd.exe", []string{"/d", "/s", "/c"}}, - {"absolute_shell", comspec, []string{"/d", "/s", "/c"}}, - {"uppercase_switch", "CMD.EXE", []string{"/D", "/S", "/C"}}, - {"without_s_switch", "cmd.exe", []string{"/d", "/c"}}, - } { - t.Run(tc.name, func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - command := `"` + child + `" -test.run=^TestCommandContextWindowsChild$ -- "hello world" "a&b" "%ONE_TEST_CMD_VALUE%" && echo after` - cmd := CommandContext(ctx, tc.shell, append(tc.flags, command)...) - cmd.Env = append(os.Environ(), "ONE_TEST_CMD_CHILD=1", "ONE_TEST_CMD_VALUE=中文 value") - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("cmd.exe shell failed: %v\n%s", err, out) - } - first, rest, ok := strings.Cut(string(out), "\n") - var args []string - if err := json.Unmarshal([]byte(first), &args); err != nil { - t.Fatalf("child did not receive arguments: %v\n%s", err, out) - } - want := []string{"hello world", "a&b", "中文 value"} - if !reflect.DeepEqual(args, want) { - t.Fatalf("child arguments = %q, want %q", args, want) - } - if !ok || strings.TrimSpace(rest) != "after" { - t.Fatalf("shell operator did not run the second command: %q", out) - } - }) - } -} - -func TestCommandContextWindowsChild(t *testing.T) { - if os.Getenv("ONE_TEST_CMD_CHILD") != "1" { - return - } - for i, arg := range os.Args { - if arg == "--" { - if err := json.NewEncoder(os.Stdout).Encode(os.Args[i+1:]); err != nil { - os.Exit(1) - } - os.Exit(0) - } - } - os.Exit(2) -} diff --git a/packages/cli/internal/platform/prompt/form_test.go b/packages/cli/internal/platform/prompt/form_test.go deleted file mode 100644 index 534a0e23..00000000 --- a/packages/cli/internal/platform/prompt/form_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package prompt - -import "testing" - -func TestNewForm_AppendsFields(t *testing.T) { - var dir, name, choice string - var confirmed bool - - f := NewForm(). - Text(&dir, "dir", "./my-app", nil). - Text(&name, "name", "billing", nil). - Select(&choice, "mode", []Option[string]{ - {Label: "fast", Value: "fast"}, - {Label: "slow", Value: "slow"}, - }). - Confirm(&confirmed, "ok?") - - if got := len(f.fields); got != 4 { - t.Errorf("fields = %d, want 4", got) - } -} - -func TestNewForm_EmptyRunIsNoop(t *testing.T) { - // An empty form should return nil without trying to start a - // bubbletea program (which would block on stdin in CI). - if err := NewForm().Run(); err != nil { - t.Errorf("empty Run() = %v, want nil", err) - } -} diff --git a/packages/cli/internal/platform/prompt/prompt_test.go b/packages/cli/internal/platform/prompt/prompt_test.go deleted file mode 100644 index bc118416..00000000 --- a/packages/cli/internal/platform/prompt/prompt_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package prompt - -import ( - stderrors "errors" - "testing" - - "charm.land/huh/v2" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" -) - -// mapErr is the only piece of prompt logic that doesn't require a TTY, -// so it's the only piece worth unit-testing here. The huh fields -// themselves drive a bubbletea program; exercising them needs a PTY, -// which is out of scope (covered by manual smoke tests in dev). - -func TestMapErr_UserAborted(t *testing.T) { - got := mapErr(huh.ErrUserAborted) - cliErr, ok := got.(*output.Error) - if !ok { - t.Fatalf("expected *output.Error, got %T", got) - } - if cliErr.Code != "PROMPT_CANCELLED" { - t.Errorf("code = %q, want PROMPT_CANCELLED", cliErr.Code) - } - if !cliErr.Exit0 { - t.Errorf("Exit0 should be true so Ctrl-C exits 0") - } -} - -func TestMapErr_UnknownError(t *testing.T) { - got := mapErr(stderrors.New("oops")) - cliErr, ok := got.(*output.Error) - if !ok { - t.Fatalf("expected *output.Error, got %T", got) - } - if cliErr.Code != "ONE_CLI_ERROR" { - t.Errorf("code = %q, want ONE_CLI_ERROR", cliErr.Code) - } - if cliErr.Exit0 { - t.Errorf("unknown errors should not be Exit0") - } -} - -func TestMapErr_Nil(t *testing.T) { - if mapErr(nil) != nil { - t.Error("mapErr(nil) should pass through") - } -} diff --git a/packages/cli/internal/platform/prompt/spinner_test.go b/packages/cli/internal/platform/prompt/spinner_test.go deleted file mode 100644 index 1a4dda7f..00000000 --- a/packages/cli/internal/platform/prompt/spinner_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package prompt - -import ( - "errors" - "testing" - "time" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" -) - -func TestSpin_NonTTY_RunsActionDirectly(t *testing.T) { - t.Cleanup(func() { output.SetMode(output.ModeAuto) }) - output.SetMode(output.ModeJSON) // pipe-equivalent: no spinner UI - - called := false - err := Spin("does not render", func() error { - called = true - return nil - }) - if err != nil { - t.Errorf("Spin returned: %v", err) - } - if !called { - t.Errorf("action was not invoked in non-TTY mode") - } -} - -func TestSpin_PropagatesActionError(t *testing.T) { - t.Cleanup(func() { output.SetMode(output.ModeAuto) }) - output.SetMode(output.ModeJSON) - - want := errors.New("boom") - got := Spin("ignored", func() error { return want }) - if !errors.Is(got, want) { - t.Errorf("Spin error = %v, want %v", got, want) - } -} - -func TestSpin_NonTTY_DoesNotBlockOnFastAction(t *testing.T) { - t.Cleanup(func() { output.SetMode(output.ModeAuto) }) - output.SetMode(output.ModeJSON) - - done := make(chan struct{}) - go func() { - _ = Spin("x", func() error { return nil }) - close(done) - }() - select { - case <-done: - // ok - case <-time.After(2 * time.Second): - t.Fatalf("Spin did not return within 2s in non-TTY mode") - } -} - -func TestStep_NonTTY_NoOp(t *testing.T) { - t.Cleanup(func() { output.SetMode(output.ModeAuto) }) - output.SetMode(output.ModeJSON) - // In non-TTY mode Step writes nothing. We can't easily intercept - // stderr without redirection plumbing, so this is mostly a smoke - // test that the function returns without panicking. - Step("hello") -} diff --git a/packages/cli/internal/platform/prompt/theme_test.go b/packages/cli/internal/platform/prompt/theme_test.go deleted file mode 100644 index 17307b19..00000000 --- a/packages/cli/internal/platform/prompt/theme_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package prompt - -import "testing" - -func TestDefaultTheme_NonNilFields(t *testing.T) { - // Smoke check that defaultTheme constructs without panic and that - // the fields huh's renderer dereferences are populated. Visual - // correctness isn't testable without a PTY; this just guards - // against regressions where a theme refactor leaves a nil Style. - tm := defaultTheme().Theme(false) - if tm == nil { - t.Fatal("defaultTheme returned nil") - } - // Render with an empty string — lipgloss styles with nil internal - // state would panic here. - _ = tm.Focused.Title.Render("title") - _ = tm.Focused.Description.Render("desc") - _ = tm.Focused.SelectSelector.Render() - _ = tm.Focused.SelectedPrefix.Render() - _ = tm.Focused.UnselectedPrefix.Render() - _ = tm.Focused.ErrorMessage.Render(" oops") - _ = tm.Focused.TextInput.Prompt.Render(">") -} - -func TestDefaultTheme_DistinctFromHuhDefault(t *testing.T) { - // Sanity check that we're actually overriding huh's defaults — if - // the override breaks (e.g. someone refactors theme.go to a no-op), - // SelectSelector should differ from huh.ThemeBase()'s "> ". - tm := defaultTheme().Theme(true) - rendered := tm.Focused.SelectSelector.Render() - if rendered == "> " { - t.Errorf("SelectSelector reverted to huh's default %q; theme override is no-op", rendered) - } -} diff --git a/packages/cli/internal/platform/updatecheck/background_test.go b/packages/cli/internal/platform/updatecheck/background_test.go deleted file mode 100644 index 40d9ab0e..00000000 --- a/packages/cli/internal/platform/updatecheck/background_test.go +++ /dev/null @@ -1,198 +0,0 @@ -package updatecheck - -import ( - "errors" - "os" - "os/exec" - "path/filepath" - "strconv" - "testing" - "time" - - "github.com/gofrs/flock" -) - -func TestUpdateLaunchIsThrottledAndLocked(t *testing.T) { - withIsolatedCache(t) - target := filepath.Join(t.TempDir(), "one") - calls := 0 - launch := func(got, dir string) error { - if got != target || dir == "" { - t.Fatalf("launch arguments: %s %s", got, dir) - } - calls++ - return nil - } - startUpdate("1.0.0", target, launch) - startUpdate("1.0.0", target, launch) - if calls != 1 { - t.Fatalf("launches = %d", calls) - } - c, err := loadCache() - if err != nil || c.Status != "checking" { - t.Fatalf("cache = %#v, %v", c, err) - } - c.LastChecked = time.Now().Add(-48 * time.Hour) - if err := saveCache(c); err != nil { - t.Fatal(err) - } - path, _ := cachePath() - lock := flock.New(path + ".lock") - if err := lock.Lock(); err != nil { - t.Fatal(err) - } - startUpdate("1.0.0", target, launch) - if err := lock.Unlock(); err != nil { - t.Fatal(err) - } - if calls != 1 { - t.Fatal("concurrent updater was launched") - } - startUpdate("1.0.0", target, launch) - if calls != 2 { - t.Fatal("stale update did not retry") - } -} - -func TestFailedLaunchPreservesInstalledProgramAndRateLimits(t *testing.T) { - withIsolatedCache(t) - target := filepath.Join(t.TempDir(), "one") - if err := os.WriteFile(target, []byte("installed"), 0o700); err != nil { - t.Fatal(err) - } - calls := 0 - launch := func(string, string) error { calls++; return errors.New("access denied") } - startUpdate("1.0.0", target, launch) - startUpdate("1.0.0", target, launch) - c, err := loadCache() - if err != nil || c.Status != "failed" || c.Error != "access denied" || calls != 1 { - t.Fatalf("cache=%#v calls=%d err=%v", c, calls, err) - } - raw, err := os.ReadFile(target) - if err != nil || string(raw) != "installed" { - t.Fatalf("target changed: %q, %v", raw, err) - } -} - -func TestWorkerEntryRejectsDevelopmentBuildsAndInvalidCalls(t *testing.T) { - withIsolatedCache(t) - if RunWorker("1.0.0", []string{"--help"}) { - t.Fatal("ordinary invocation consumed") - } - before := buildChannel - t.Cleanup(func() { buildChannel = before }) - buildChannel = "development" - if !RunWorker("1.0.0", []string{workerCommand, "/unused", "1"}) { - t.Fatal("worker invocation not consumed") - } - buildChannel = "release" - if !RunWorker("1.0.0-local.abc1234", []string{workerCommand, "/unused", "1"}) { - t.Fatal("local invocation not consumed") - } - c, err := loadCache() - if err != nil || c != nil { - t.Fatalf("disabled worker changed cache: %#v, %v", c, err) - } -} - -func TestDetachedWorkerSurvivesLauncher(t *testing.T) { - marker := filepath.Join(t.TempDir(), "finished") - launcher := exec.Command(os.Args[0], "-test.run=^TestDetachedWorkerHelper$") - launcher.Env = append(os.Environ(), "ONE_UPDATE_TEST_ROLE=launcher", "ONE_UPDATE_TEST_MARKER="+marker) - if raw, err := launcher.CombinedOutput(); err != nil { - t.Fatalf("launcher: %v %s", err, raw) - } - deadline := time.Now().Add(10 * time.Second) - for time.Now().Before(deadline) { - raw, err := os.ReadFile(marker) - if err == nil { - if string(raw) != "finished" { - t.Fatalf("worker result: %q", raw) - } - return - } - time.Sleep(10 * time.Millisecond) - } - t.Fatal("detached updater did not survive its launching command") -} - -func TestDetachedWorkerHelper(t *testing.T) { - switch os.Getenv("ONE_UPDATE_TEST_ROLE") { - case "launcher": - worker := exec.Command(os.Args[0], "-test.run=^TestDetachedWorkerHelper$") - worker.Env = append(os.Environ(), "ONE_UPDATE_TEST_ROLE=worker", "ONE_UPDATE_TEST_PARENT="+strconv.Itoa(os.Getpid())) - detachWorker(worker) - if err := worker.Start(); err != nil { - os.Exit(2) - } - _ = worker.Process.Release() - os.Exit(0) - case "worker": - parent, _ := strconv.Atoi(os.Getenv("ONE_UPDATE_TEST_PARENT")) - wait, closeParent, err := parentExitWaiter(parent) - if err != nil { - os.Exit(3) - } - if err := wait(); err != nil { - closeParent() - os.Exit(4) - } - closeParent() - time.Sleep(100 * time.Millisecond) - // Publish completion only after the contents are fully written and closed. - // Writing the marker directly lets the parent observe an empty file. - marker := os.Getenv("ONE_UPDATE_TEST_MARKER") - if err := os.WriteFile(marker+".tmp", []byte("finished"), 0o600); err != nil { - os.Exit(5) - } - if err := os.Rename(marker+".tmp", marker); err != nil { - os.Exit(6) - } - os.Exit(0) - } -} - -func TestWorkerDirectoryValidation(t *testing.T) { - root := t.TempDir() - cacheDir := filepath.Join(root, "cache") - workerDir := filepath.Join(cacheDir, workerPrefix+"test") - otherDir := filepath.Join(root, "other", workerPrefix+"test") - unprefixedDir := filepath.Join(cacheDir, "ordinary") - for _, dir := range []string{workerDir, otherDir, unprefixedDir} { - if err := os.MkdirAll(dir, 0o700); err != nil { - t.Fatal(err) - } - } - check := func(t *testing.T, workerDir, cacheDir string, want bool) { - t.Helper() - resolved, valid := validatedWorkerDir(filepath.Join(workerDir, executableName()), cacheDir) - if valid != want { - t.Fatalf("worker %q in cache %q: valid = %v, want %v", workerDir, cacheDir, valid, want) - } - if valid { - wantDir, err := filepath.EvalSymlinks(workerDir) - if err != nil || resolved != wantDir { - t.Fatalf("worker directory = %q, want %q: %v", resolved, wantDir, err) - } - } - } - check(t, workerDir, cacheDir, true) - check(t, otherDir, cacheDir, false) - check(t, unprefixedDir, cacheDir, false) - check(t, workerDir, filepath.Join(root, "missing"), false) - t.Run("path aliases", func(t *testing.T) { - alias := filepath.Join(root, "alias") - if err := os.Symlink(cacheDir, alias); err != nil { - t.Skipf("directory symlinks unavailable: %v", err) - } - check(t, filepath.Join(alias, filepath.Base(workerDir)), cacheDir, true) - check(t, workerDir, alias, true) - check(t, filepath.Join(alias, filepath.Base(workerDir)), alias, true) - // A worker-shaped symlink into another directory must remain invalid. - escaped := filepath.Join(cacheDir, workerPrefix+"outside") - if err := os.Symlink(otherDir, escaped); err != nil { - t.Fatal(err) - } - check(t, escaped, cacheDir, false) - }) -} diff --git a/packages/cli/internal/platform/updatecheck/cache_test.go b/packages/cli/internal/platform/updatecheck/cache_test.go deleted file mode 100644 index b4f0a89f..00000000 --- a/packages/cli/internal/platform/updatecheck/cache_test.go +++ /dev/null @@ -1,120 +0,0 @@ -package updatecheck - -import ( - "os" - "path/filepath" - "testing" - "time" -) - -// withIsolatedCache redirects XDG_CACHE_HOME and HOME to a tmpdir so -// load/save touch test-local files only. Mirrors profile pkg's -// withIsolatedConfig. -func withIsolatedCache(t *testing.T) string { - t.Helper() - tmp := t.TempDir() - t.Setenv("XDG_CACHE_HOME", tmp) - t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmp, "config")) - t.Setenv("HOME", tmp) - return tmp -} - -func TestCachePath_HonoursXDG(t *testing.T) { - tmp := withIsolatedCache(t) - path, err := cachePath() - if err != nil { - t.Fatalf("cachePath: %v", err) - } - want := filepath.Join(tmp, "one", "update-check.json") - if path != want { - t.Errorf("cachePath: got %q, want %q", path, want) - } -} - -func TestCachePath_FallbackToHomeWhenNoXDG(t *testing.T) { - tmp := t.TempDir() - t.Setenv("XDG_CACHE_HOME", "") - t.Setenv("HOME", tmp) - path, err := cachePath() - if err != nil { - t.Fatalf("cachePath: %v", err) - } - want := filepath.Join(tmp, ".cache", "one", "update-check.json") - if path != want { - t.Errorf("cachePath: got %q, want %q", path, want) - } -} - -func TestLoadCache_MissingFile_ReturnsNilNoError(t *testing.T) { - withIsolatedCache(t) - c, err := loadCache() - if err != nil { - t.Fatalf("loadCache: %v", err) - } - if c != nil { - t.Errorf("expected nil cache, got %+v", c) - } -} - -func TestSaveLoadRoundTrip(t *testing.T) { - withIsolatedCache(t) - now := time.Now().UTC().Truncate(time.Second) - in := &Cache{ - LastChecked: now, - LatestVersion: "v0.9.0", - } - if err := saveCache(in); err != nil { - t.Fatalf("saveCache: %v", err) - } - out, err := loadCache() - if err != nil { - t.Fatalf("loadCache: %v", err) - } - if out == nil { - t.Fatal("loadCache returned nil after save") - } - if out.Schema != cacheSchema { - t.Errorf("schema: got %q, want %q", out.Schema, cacheSchema) - } - if out.LatestVersion != in.LatestVersion { - t.Errorf("LatestVersion: got %q, want %q", out.LatestVersion, in.LatestVersion) - } - if !out.LastChecked.Equal(in.LastChecked) { - t.Errorf("LastChecked drift: got %v, want %v", out.LastChecked, in.LastChecked) - } -} - -func TestLoadCache_CorruptJSON_ReturnsError(t *testing.T) { - withIsolatedCache(t) - path, _ := cachePath() - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatalf("mkdir: %v", err) - } - if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil { - t.Fatalf("write: %v", err) - } - c, err := loadCache() - if err == nil { - t.Errorf("expected error, got cache=%+v", c) - } -} - -func TestSaveCache_AtomicRename_NoPartialFile(t *testing.T) { - withIsolatedCache(t) - if err := saveCache(&Cache{LastChecked: time.Now(), LatestVersion: "v1.0.0"}); err != nil { - t.Fatalf("save: %v", err) - } - path, _ := cachePath() - dir := filepath.Dir(path) - entries, err := os.ReadDir(dir) - if err != nil { - t.Fatalf("readdir: %v", err) - } - // After save: only the canonical file should remain (no .update-check-*.json - // temp file leftovers). - for _, e := range entries { - if e.Name() != "update-check.json" { - t.Errorf("unexpected leftover file: %s", e.Name()) - } - } -} diff --git a/packages/cli/internal/platform/updatecheck/checker_test.go b/packages/cli/internal/platform/updatecheck/checker_test.go deleted file mode 100644 index 994fc94d..00000000 --- a/packages/cli/internal/platform/updatecheck/checker_test.go +++ /dev/null @@ -1,88 +0,0 @@ -package updatecheck - -import ( - "context" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -// Exercise the production fetch path against a local server. -func fetchAt(ctx context.Context, url, currentVersion string) (string, error) { - return fetchLatestUsing(ctx, http.DefaultClient, url, currentVersion) -} - -func TestFetch_Happy(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !strings.HasPrefix(r.Header.Get("User-Agent"), "one-cli/") { - t.Errorf("missing/bad User-Agent: %q", r.Header.Get("User-Agent")) - } - w.WriteHeader(200) - _, _ = w.Write([]byte("v0.9.0\n")) - })) - defer srv.Close() - got, err := fetchAt(context.Background(), srv.URL, "v0.8.0") - if err != nil { - t.Fatalf("fetch: %v", err) - } - if got != "v0.9.0" { - t.Errorf("got %q, want v0.9.0", got) - } -} - -func TestFetch_GitHubLatestRedirect(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/latest": - http.Redirect(w, r, "/releases/tag/v0.9.0", http.StatusFound) - case "/releases/tag/v0.9.0": - _, _ = w.Write([]byte("release page")) - default: - t.Fatalf("unexpected path: %s", r.URL.Path) - } - })) - defer srv.Close() - got, err := fetchAt(context.Background(), srv.URL+"/latest", "v0.8.0") - if err != nil { - t.Fatalf("fetch: %v", err) - } - if got != "v0.9.0" { - t.Errorf("got %q, want v0.9.0", got) - } -} - -func TestFetch_Non2xx(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(503) - })) - defer srv.Close() - _, err := fetchAt(context.Background(), srv.URL, "v0.8.0") - if err == nil { - t.Errorf("expected error for 503, got nil") - } -} - -func TestFetch_GarbageBody(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(200) - _, _ = w.Write([]byte("not a version")) - })) - defer srv.Close() - _, err := fetchAt(context.Background(), srv.URL, "v0.8.0") - if err == nil { - t.Errorf("expected error for garbage body, got nil") - } -} - -// TestFetchLatest_LiveURL is a sanity check that the production endpoint -// is well-formed when reachable. Skipped by default (`go test -tags=net`) -// — we don't want pre-push to fail when a developer is offline. -func TestFetchLatest_RealURL_Skipped(t *testing.T) { - t.Skip("opt-in network probe; run manually with `go test -run RealURL ./internal/platform/updatecheck`") - v, err := fetchLatest(context.Background(), "v0.0.0-test") - if err != nil { - t.Fatalf("real fetch: %v", err) - } - t.Logf("live latest release = %s", v) -} diff --git a/packages/cli/internal/platform/updatecheck/install_test.go b/packages/cli/internal/platform/updatecheck/install_test.go deleted file mode 100644 index 7ddaf3f3..00000000 --- a/packages/cli/internal/platform/updatecheck/install_test.go +++ /dev/null @@ -1,219 +0,0 @@ -package updatecheck - -import ( - "archive/tar" - "archive/zip" - "bytes" - "compress/gzip" - "context" - "crypto/sha256" - "fmt" - "io" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" -) - -func testArchive(t *testing.T, goos string, names []string, body []byte, symlink bool) []byte { - t.Helper() - var out bytes.Buffer - if goos == "windows" { - writer := zip.NewWriter(&out) - for _, name := range names { - header := &zip.FileHeader{Name: name, Method: zip.Deflate} - mode := os.FileMode(0o755) - if symlink { - mode |= os.ModeSymlink - } - header.SetMode(mode) - entry, err := writer.CreateHeader(header) - if err != nil { - t.Fatal(err) - } - if _, err := entry.Write(body); err != nil { - t.Fatal(err) - } - } - if err := writer.Close(); err != nil { - t.Fatal(err) - } - } else { - compressed := gzip.NewWriter(&out) - writer := tar.NewWriter(compressed) - for _, name := range names { - header := &tar.Header{Name: name, Mode: 0o755, Size: int64(len(body)), Typeflag: tar.TypeReg} - if symlink { - header.Typeflag, header.Linkname, header.Size = tar.TypeSymlink, "elsewhere", 0 - } - if err := writer.WriteHeader(header); err != nil { - t.Fatal(err) - } - if !symlink { - if _, err := writer.Write(body); err != nil { - t.Fatal(err) - } - } - } - if err := writer.Close(); err != nil { - t.Fatal(err) - } - if err := compressed.Close(); err != nil { - t.Fatal(err) - } - } - return out.Bytes() -} - -func TestAutomaticUpdateInstallsOnlyVerifiedReleases(t *testing.T) { - for _, tc := range []struct{ name, goos, latest, failure string }{ - {"tar success", "linux", "v1.2.3", ""}, - {"zip success", "windows", "v1.2.3", ""}, - {"same version", "linux", "v1.0.0", ""}, - {"older version", "linux", "v0.9.0", ""}, - {"prerelease feed", "linux", "v1.2.3-rc.1", "prerelease"}, - {"checksum mismatch", "linux", "v1.2.3", "checksum"}, - {"missing checksum", "linux", "v1.2.3", "missing-checksum"}, - {"duplicate checksum", "linux", "v1.2.3", "duplicate-checksum"}, - {"download unavailable", "linux", "v1.2.3", "download"}, - {"wrong executable version", "linux", "v1.2.3", "probe"}, - {"concurrent developer build", "linux", "v1.2.3", "changed"}, - } { - t.Run(tc.name, func(t *testing.T) { - dir := t.TempDir() - target := filepath.Join(dir, "one") - old, candidate := []byte("installed release"), []byte("verified new release") - if err := os.WriteFile(target, old, 0o700); err != nil { - t.Fatal(err) - } - expected, err := executableDigest(target) - if err != nil { - t.Fatal(err) - } - binaryName, ext := "one", ".tar.gz" - if tc.goos == "windows" { - binaryName, ext = "one.exe", ".zip" - } - name := "one-cli_" + tc.goos + "_amd64" + ext - archive := testArchive(t, tc.goos, []string{binaryName}, candidate, false) - digest := fmt.Sprintf("%x", sha256.Sum256(archive)) - probed, waited, downloaded := false, false, false - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/latest": - io.WriteString(w, tc.latest) - case "/v1.2.3/checksums.txt": - entry := digest + " " + name + "\n" - switch tc.failure { - case "checksum": - entry = strings.Repeat("0", 64) + " " + name + "\n" - case "missing-checksum": - entry = digest + " other.tar.gz\n" - case "duplicate-checksum": - entry += entry - } - io.WriteString(w, entry) - case "/v1.2.3/" + name: - downloaded = true - if tc.failure == "download" { - w.WriteHeader(http.StatusServiceUnavailable) - return - } - w.Write(archive) - default: - t.Errorf("unexpected request %s", r.URL.Path) - w.WriteHeader(404) - } - })) - defer server.Close() - u := updater{client: server.Client(), latestURL: server.URL + "/latest", releaseURL: server.URL, goos: tc.goos, goarch: "amd64", - probe: func(_ context.Context, path, version string) error { - probed = true - raw, err := os.ReadFile(path) - if err != nil || !bytes.Equal(raw, candidate) || version != tc.latest { - t.Fatalf("candidate = %q, version = %s, err = %v", raw, version, err) - } - if tc.failure == "probe" { - return fmt.Errorf("unexpected version") - } - if tc.failure == "changed" { - return os.WriteFile(target, []byte("new developer build"), 0o700) - } - return nil - }, beforeInstall: func(string) error { waited = true; return nil }, - } - _, installed, err := u.update(context.Background(), target, "1.0.0", expected) - if (err != nil) != (tc.failure != "") { - t.Fatalf("error = %v", err) - } - raw, readErr := os.ReadFile(target) - if readErr != nil { - t.Fatal(readErr) - } - want := old - success := tc.latest == "v1.2.3" && tc.failure == "" - if success { - want = candidate - if installed != tc.latest || !probed || !waited { - t.Fatalf("update not completed: %q %v %v", installed, probed, waited) - } - } else if installed != "" { - t.Fatalf("failed/skipped update reported installed %s", installed) - } - if tc.failure == "changed" { - want = []byte("new developer build") - } - if !bytes.Equal(raw, want) { - t.Fatalf("installed content = %q, want %q", raw, want) - } - if (tc.latest == "v1.0.0" || tc.latest == "v0.9.0") && (probed || downloaded) { - t.Fatal("same/older release was downloaded") - } - entries, err := os.ReadDir(dir) - if err != nil || len(entries) != 1 { - t.Fatalf("temporary downloads leaked: %v %v", entries, err) - } - }) - } -} - -func TestUpdateArchiveRejectsUnsafeOrAmbiguousExecutables(t *testing.T) { - for _, goos := range []string{"linux", "windows"} { - name := "one" - if goos == "windows" { - name = "one.exe" - } - for _, tc := range []struct { - name string - names []string - symlink bool - }{ - {"path traversal", []string{"../" + name}, false}, - {"duplicate", []string{name, name}, false}, - {"symlink", []string{name}, true}, - {"missing", []string{"README.md"}, false}, - } { - t.Run(goos+"/"+tc.name, func(t *testing.T) { - path := filepath.Join(t.TempDir(), "archive") - if err := os.WriteFile(path, testArchive(t, goos, tc.names, []byte("candidate"), tc.symlink), 0o600); err != nil { - t.Fatal(err) - } - var out bytes.Buffer - if err := extractExecutable(path, goos, &out); err == nil { - t.Fatal("invalid archive accepted") - } - }) - } - } -} - -func TestUpdateSkipsDevelopmentVersionsBeforeNetwork(t *testing.T) { - u := updater{} // accessing the client or probe would panic - for _, version := range developmentVersions { - if _, installed, err := u.update(context.Background(), "unused", version, "unused"); err == nil || installed != "" { - t.Fatalf("%q: %q %v", version, installed, err) - } - } -} diff --git a/packages/cli/internal/platform/updatecheck/manual_test.go b/packages/cli/internal/platform/updatecheck/manual_test.go deleted file mode 100644 index e1b60a06..00000000 --- a/packages/cli/internal/platform/updatecheck/manual_test.go +++ /dev/null @@ -1,65 +0,0 @@ -package updatecheck - -import ( - "context" - "errors" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/gofrs/flock" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" -) - -func TestManualUpdateRejectsDevelopmentBuildWithoutSideEffects(t *testing.T) { - withIsolatedCache(t) - before := buildChannel - t.Cleanup(func() { buildChannel = before; _ = i18n.Init(i18n.DefaultLocale) }) - for _, locale := range []string{"en-US", "zh-CN"} { - _ = i18n.Init(locale) - for _, channel := range []string{"development", "release"} { - buildChannel = channel - versions := developmentVersions - if channel == "development" { - versions = append(append([]string{}, versions...), "1.2.3") - } - for _, version := range versions { - result, err := Upgrade(context.Background(), version) - if result != nil || err == nil || !strings.Contains(err.Error(), installCommand("linux")) && !strings.Contains(err.Error(), installCommand("windows")) { - t.Fatalf("%s %s %s: %#v %v", locale, channel, version, result, err) - } - } - } - } - path, _ := cachePath() - if _, err := os.Stat(filepath.Dir(path)); !os.IsNotExist(err) { - t.Fatalf("development update touched cache: %v", err) - } -} - -func TestManualUpdateWaitsForExistingUpdaterAndHonorsCancellation(t *testing.T) { - withIsolatedCache(t) - if err := saveCache(&Cache{Status: "checking"}); err != nil { - t.Fatal(err) - } - path, _ := cachePath() - lock := flock.New(path + ".lock") - if err := lock.Lock(); err != nil { - t.Fatal(err) - } - defer lock.Unlock() - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) - defer cancel() - // No client: reaching the network while the other updater holds the - // installation lock would panic instead of safely waiting. - result, err := upgradeLocked(ctx, updater{}, "unused", "1.0.0", "unused") - if result != nil || !errors.Is(err, context.DeadlineExceeded) { - t.Fatalf("canceled update: %#v %v", result, err) - } - c, err := loadCache() - if err != nil || c.Status != "checking" || c.CurrentVersion != "" { - t.Fatalf("active updater's state was changed: %#v %v", c, err) - } -} diff --git a/packages/cli/internal/platform/updatecheck/notify_test.go b/packages/cli/internal/platform/updatecheck/notify_test.go deleted file mode 100644 index 4b0cc23e..00000000 --- a/packages/cli/internal/platform/updatecheck/notify_test.go +++ /dev/null @@ -1,239 +0,0 @@ -package updatecheck - -import ( - "bytes" - "os" - "strings" - "testing" - "time" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" -) - -// withTTY forces output mode to TTY for the duration of the test so the -// IsTTY-based gate doesn't reject the notification path. Resets to -// ModeAuto on cleanup — the package's exported zero default — since -// internal/platform/output doesn't surface a getter for the current mode. -func withTTY(t *testing.T) { - t.Helper() - originalChannel := buildChannel - buildChannel = "release" - t.Cleanup(func() { buildChannel = originalChannel }) - output.SetMode(output.ModeTTY) - t.Cleanup(func() { output.SetMode(output.ModeAuto) }) -} - -// clearCIEnv unsets CI-detection env vars that may be inherited from the -// test runner (especially relevant when running these tests in CI itself). -func clearCIEnv(t *testing.T) { - t.Helper() - for _, k := range []string{"CI", "GITHUB_ACTIONS", "GITLAB_CI", "CIRCLECI", "BUILDKITE"} { - t.Setenv(k, "") - } -} - -func TestShouldSkip_CI(t *testing.T) { - withTTY(t) - clearCIEnv(t) - t.Setenv("CI", "true") - if !shouldSkip("v0.8.0") { - t.Errorf("expected skip when CI is set") - } -} - -func TestShouldSkip_NonTTY(t *testing.T) { - withTTY(t) - clearCIEnv(t) - output.SetMode(output.ModeJSON) - t.Cleanup(func() { output.SetMode(output.ModeAuto) }) - if !shouldSkip("v0.8.0") { - t.Errorf("expected skip when output mode is JSON (non-TTY)") - } -} - -var developmentVersions = []string{ - "", "dev", "unknown", "0.0.0", "v0.0.0", "0.0.0-dev", - "0.0.0-dev-local.abc1234", "0.0.0-dev-local.abc1234.dirty", - "1.2.3-local.abc1234", "1.2.3-local.abc1234.dirty", - "v1.2.3-dev", "1.2.3-SNAPSHOT-abc1234", "v1.2.3-rc.1", - "1.2.3+local.abc1234", "v1", "1.2", "01.2.3", "1.2.3.4", -} - -func TestShouldSkip_DevVersion(t *testing.T) { - withTTY(t) - clearCIEnv(t) - for _, version := range developmentVersions { - if !shouldSkip(version) { - t.Errorf("expected skip for development/unknown build %q", version) - } - } -} - -func TestDevelopmentBuildsNeverRefreshOrNotify(t *testing.T) { - withTTY(t) - clearCIEnv(t) - withIsolatedCache(t) - cached := &Cache{LastChecked: time.Now().Add(-48 * time.Hour).UTC(), LatestVersion: "v99.0.0"} - if err := saveCache(cached); err != nil { - t.Fatal(err) - } - path, err := cachePath() - if err != nil { - t.Fatal(err) - } - before, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - for _, version := range developmentVersions { - MaybeRefreshAsync(version) - - if got := captureStderr(t, func() { Notify(version) }); got != "" { - t.Fatalf("development build %q printed an update: %q", version, got) - } - } - after, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(before, after) { - t.Fatal("development update check rewrote the cache") - } -} - -func TestShouldSkip_HappyPath(t *testing.T) { - withTTY(t) - clearCIEnv(t) - for _, version := range []string{"v0.8.0", "0.8.0", "1.2.3", "v10.20.30"} { - if shouldSkip(version) { - t.Errorf("expected no skip for stable release %q", version) - } - } -} - -func TestPrintWarning_ContainsBothLines(t *testing.T) { - // Capture stderr by piping through a tmpfile. - tmp, err := os.CreateTemp(t.TempDir(), "stderr-*.txt") - if err != nil { - t.Fatalf("tmp: %v", err) - } - defer tmp.Close() - printWarning(tmp, "v0.9.0", "v0.8.0") - _, _ = tmp.Seek(0, 0) - var buf bytes.Buffer - if _, err := buf.ReadFrom(tmp); err != nil { - t.Fatalf("read: %v", err) - } - got := buf.String() - for _, want := range []string{"v0.9.0", "v0.8.0", "one upgrade", "⚠"} { - if !strings.Contains(got, want) { - t.Errorf("warning missing %q\n got: %s", want, got) - } - } -} - -func TestInstallCommandMatchesPlatform(t *testing.T) { - if got := installCommand("windows"); got != windowsInstallCommand { - t.Fatalf("Windows install command = %q", got) - } - if got := installCommand("linux"); got != unixInstallCommand { - t.Fatalf("Unix install command = %q", got) - } -} - -// Notify is the integration: skip rules + cache read + format. Wires up -// the cache directly so we don't go anywhere near the network. -func TestNotify_PrintsWhenNewerCached(t *testing.T) { - withTTY(t) - clearCIEnv(t) - withIsolatedCache(t) - // Seed cache with a strictly newer version. - if err := saveCache(&Cache{ - LastChecked: time.Now().UTC(), - LatestVersion: "v0.9.0", - }); err != nil { - t.Fatalf("seed cache: %v", err) - } - // Capture stderr by replacing os.Stderr around the call. - got := captureStderr(t, func() { Notify("v0.8.0") }) - if !strings.Contains(got, "v0.9.0") { - t.Errorf("expected notification on stderr, got %q", got) - } -} - -func TestNotify_QuietWhenSameOrOlder(t *testing.T) { - withTTY(t) - clearCIEnv(t) - withIsolatedCache(t) - if err := saveCache(&Cache{ - LastChecked: time.Now().UTC(), - LatestVersion: "v0.8.0", - }); err != nil { - t.Fatalf("seed: %v", err) - } - got := captureStderr(t, func() { Notify("v0.8.0") }) - if got != "" { - t.Errorf("expected silence when versions match, got %q", got) - } -} - -// captureStderr swaps os.Stderr for a pipe, runs fn, and returns the -// captured output. Restores the original on exit. -func captureStderr(t *testing.T, fn func()) string { - t.Helper() - r, w, err := os.Pipe() - if err != nil { - t.Fatalf("pipe: %v", err) - } - orig := os.Stderr - os.Stderr = w - t.Cleanup(func() { os.Stderr = orig }) - - done := make(chan string, 1) - go func() { - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - done <- buf.String() - }() - - fn() - _ = w.Close() - return <-done -} - -func TestDevelopmentBuildWithReleaseVersionStillSkips(t *testing.T) { - withTTY(t) - clearCIEnv(t) - buildChannel = "development" - if !shouldSkip("1.2.3") { - t.Fatal("version override enabled updates in a source build") - } -} - -func TestNotifyInstalledUpdateOnceInBothLanguages(t *testing.T) { - withTTY(t) - clearCIEnv(t) - withIsolatedCache(t) - target, err := os.Executable() - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = i18n.Init(i18n.DefaultLocale) }) - for _, locale := range []string{"en-US", "zh-CN"} { - if err := i18n.Init(locale); err != nil { - t.Fatal(err) - } - c := &Cache{LastChecked: time.Now(), TargetPath: target, CurrentVersion: "1.0.0", LatestVersion: "v1.2.3", InstalledVersion: "v1.2.3", Status: "updated", NotificationPending: true} - if err := saveCache(c); err != nil { - t.Fatal(err) - } - got := captureStderr(t, func() { Notify("1.2.3") }) - if !strings.Contains(got, i18n.Tf("update.installed", "v1.2.3")) { - t.Fatalf("%s notification: %q", locale, got) - } - if got := captureStderr(t, func() { Notify("1.2.3") }); got != "" { - t.Fatalf("notification repeated: %q", got) - } - } -} diff --git a/packages/cli/internal/platform/updatecheck/semver_test.go b/packages/cli/internal/platform/updatecheck/semver_test.go deleted file mode 100644 index adad72e5..00000000 --- a/packages/cli/internal/platform/updatecheck/semver_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package updatecheck - -import "testing" - -func TestIsNewer(t *testing.T) { - cases := []struct { - latest, current string - want bool - }{ - // strictly greater triples - {"v0.9.0", "v0.8.0", true}, - {"v0.8.1", "v0.8.0", true}, - {"v1.0.0", "v0.99.99", true}, - {"v10.0.0", "v9.0.0", true}, - - // equal - {"v0.8.0", "v0.8.0", false}, - - // strictly older - {"v0.7.0", "v0.8.0", false}, - {"v0.8.0", "v0.8.1", false}, - - // pre-release suffixes are stripped — same triple = not newer - {"v0.9.0-rc1", "v0.9.0", false}, - {"v0.9.0", "v0.9.0-rc1", false}, - // but a higher base still wins despite a suffix - {"v0.9.0-rc1", "v0.8.0", true}, - - // missing segments default to 0 (matches install.sh) - {"v1", "v1.0.0", false}, - {"v1.1", "v1.0.0", true}, - - // no leading v - {"0.9.0", "v0.8.0", true}, - - // garbage → never newer - {"", "v0.8.0", false}, - {"v0.8.0", "", false}, - {"abc", "v0.8.0", false}, - {"v0.8.x", "v0.8.0", false}, - {"v0..0", "v0.8.0", false}, - {"v-1.0.0", "v0.8.0", false}, - } - for _, c := range cases { - got := isNewer(c.latest, c.current) - if got != c.want { - t.Errorf("isNewer(%q, %q) = %v, want %v", c.latest, c.current, got, c.want) - } - } -} - -func TestNormalizeTag(t *testing.T) { - cases := []struct { - in, want string - }{ - {"v0.8.0", "v0.8.0"}, - {"0.8.0", "v0.8.0"}, - {"v0.8.0\n", "v0.8.0"}, - {" v0.8.0 ", "v0.8.0"}, - {"v0.8.0-rc1", ""}, // never turn a prerelease into a stable download - {"v1", ""}, // incomplete versions are not release tags - {"v1.2", ""}, - {"", ""}, - {"abc", ""}, - {"v9.9.9.9", ""}, // too many segments - } - for _, c := range cases { - got := normalizeTag(c.in) - if got != c.want { - t.Errorf("normalizeTag(%q) = %q, want %q", c.in, got, c.want) - } - } -} diff --git a/packages/cli/internal/platform/updatecheck/worker_integration_test.go b/packages/cli/internal/platform/updatecheck/worker_integration_test.go deleted file mode 100644 index ad2cd828..00000000 --- a/packages/cli/internal/platform/updatecheck/worker_integration_test.go +++ /dev/null @@ -1,264 +0,0 @@ -package updatecheck - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "runtime" - "testing" - "time" -) - -// The copied test executable acts as the release binary in the subprocess -// test. Endpoint overrides and version replies exist only in this test entry. -func TestMain(m *testing.M) { - if os.Getenv("ONE_UPDATE_TEST_INTEGRATION") == "1" { - if len(os.Args) == 2 && os.Args[1] == "--version" { - fmt.Println("1.2.3") - os.Exit(0) - } - buildChannel = "release" - makeUpdater := func() updater { - u := defaultUpdater() - u.client = http.DefaultClient - u.latestURL = os.Getenv("ONE_UPDATE_TEST_SERVER") + "/latest" - u.releaseURL = os.Getenv("ONE_UPDATE_TEST_SERVER") - return u - } - if runManualWorker("1.0.0", os.Args[1:], makeUpdater) || runWorker("1.0.0", os.Args[1:], makeUpdater) { - os.Exit(0) - } - } - os.Exit(m.Run()) -} - -func TestManualWorkerChecksImmediatelyAndReportsResult(t *testing.T) { - for _, tc := range []struct { - name, latest string - fail bool - pathAlias bool - }{ - {"upgrade", "v1.2.3", false, false}, - {"already current", "v1.0.0", false, false}, - {"no downgrade", "v0.9.0", false, false}, - {"checksum failure", "v1.2.3", true, false}, - {"path alias", "v1.2.3", false, true}, - } { - t.Run(tc.name, func(t *testing.T) { - withIsolatedCache(t) - target := filepath.Join(t.TempDir(), executableName()) - if err := copyExecutable(os.Args[0], target); err != nil { - t.Fatal(err) - } - if tc.pathAlias { - alias := filepath.Join(t.TempDir(), "install") - if err := os.Symlink(filepath.Dir(target), alias); err != nil { - t.Skipf("directory symlinks unavailable: %v", err) - } - target = filepath.Join(alias, executableName()) - } - // Upgrade returns the canonical executable path. macOS /var aliases - // and Windows short paths can differ from t.TempDir's spelling. - canonicalTarget, err := filepath.EvalSymlinks(target) - if err != nil { - t.Fatal(err) - } - payload, err := os.ReadFile(target) - if err != nil { - t.Fatal(err) - } - archive := testArchive(t, runtime.GOOS, []string{executableName()}, payload, false) - ext := ".tar.gz" - if runtime.GOOS == "windows" { - ext = ".zip" - } - name := "one-cli_" + runtime.GOOS + "_" + runtime.GOARCH + ext - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/latest": - fmt.Fprintln(w, tc.latest) - case "/v1.2.3/checksums.txt": - digest := sha256.Sum256(archive) - if tc.fail { - digest = [32]byte{} - } - fmt.Fprintf(w, "%x %s\n", digest, name) - case "/v1.2.3/" + name: - w.Write(archive) - default: - t.Errorf("unexpected download %s", r.URL.Path) - w.WriteHeader(http.StatusNotFound) - } - })) - defer server.Close() - // A fresh cache and CI would suppress an automatic update. - if err := saveCache(&Cache{LastChecked: time.Now().UTC(), CurrentVersion: "1.0.0", TargetPath: canonicalTarget, Status: "current"}); err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) - defer cancel() - launcher := exec.CommandContext(ctx, target, "-test.run=^TestManualUpdateIntegrationLauncher$") - launcher.Env = append(os.Environ(), "CI=true", "ONE_UPDATE_TEST_INTEGRATION=1", "ONE_UPDATE_TEST_SERVER="+server.URL) - raw, err := launcher.CombinedOutput() - if err != nil { - t.Fatalf("manual command: %v %s", err, raw) - } - var reply manualReply - if err := json.Unmarshal(raw, &reply); err != nil { - t.Fatalf("manual result %q: %v", raw, err) - } - if (reply.Error != "") != tc.fail { - t.Fatalf("unexpected reply: %#v", reply) - } - if !tc.fail { - want := "current" - if tc.latest == "v1.2.3" { - want = "updated" - if runtime.GOOS == "windows" { - want = "pending" - } - } - if reply.Result == nil || reply.Result.Status != want || reply.Result.LatestVersion != tc.latest || reply.Result.TargetPath != canonicalTarget { - t.Fatalf("unexpected result: %#v", reply.Result) - } - } - // Windows installation completes after the launcher has exited. - deadline := time.Now().Add(10 * time.Second) - for { - c, err := loadCache() - if err == nil && c != nil && c.Status != "checking" { - want := "current" - if tc.fail { - want = "failed" - } else if tc.latest == "v1.2.3" { - want = "updated" - } - if c.Status != want || c.LatestVersion != tc.latest { - t.Fatalf("completion state: %#v", c) - } - break - } - if time.Now().After(deadline) { - t.Fatalf("manual worker did not finish: %#v %v", c, err) - } - time.Sleep(20 * time.Millisecond) - } - got, err := os.ReadFile(target) - if err != nil || !bytes.Equal(got, payload) { - // The test release contains the test executable itself. - // Every path must leave a complete, runnable program. - t.Fatalf("installed executable differs: %v", err) - } - }) - } -} - -func TestManualUpdateIntegrationLauncher(t *testing.T) { - if os.Getenv("ONE_UPDATE_TEST_INTEGRATION") != "1" { - return - } - result, err := Upgrade(context.Background(), "1.0.0") - reply := manualReply{Result: result} - if err != nil { - reply.Error = err.Error() - } - _ = json.NewEncoder(os.Stdout).Encode(reply) - os.Exit(0) -} - -func TestBackgroundWorkerCompletesAfterCommandExits(t *testing.T) { - withIsolatedCache(t) - clearCIEnv(t) - target := filepath.Join(t.TempDir(), executableName()) - if err := copyExecutable(os.Args[0], target); err != nil { - t.Fatal(err) - } - payload, err := os.ReadFile(target) - if err != nil { - t.Fatal(err) - } - archive := testArchive(t, runtime.GOOS, []string{executableName()}, payload, false) - ext := ".tar.gz" - if runtime.GOOS == "windows" { - ext = ".zip" - } - name := "one-cli_" + runtime.GOOS + "_" + runtime.GOARCH + ext - // Do not let the archive request finish until the foreground command exits. - // An in-process goroutine would die without installing this release. - resume := make(chan struct{}) - resumed := false - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/latest": - fmt.Fprintln(w, "v1.2.3") - case "/v1.2.3/checksums.txt": - fmt.Fprintf(w, "%x %s\n", sha256.Sum256(archive), name) - case "/v1.2.3/" + name: - select { - case <-resume: - case <-r.Context().Done(): - return - } - w.Write(archive) - default: - w.WriteHeader(http.StatusNotFound) - } - })) - defer func() { - if !resumed { - close(resume) - } - server.Close() - }() - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - launcher := exec.CommandContext(ctx, target, "-test.run=^TestUpdateIntegrationLauncher$") - launcher.Env = append(os.Environ(), "ONE_UPDATE_TEST_INTEGRATION=1", "ONE_UPDATE_TEST_SERVER="+server.URL) - if raw, err := launcher.CombinedOutput(); err != nil { - t.Fatalf("foreground command: %v %s", err, raw) - } - close(resume) - resumed = true - deadline := time.Now().Add(15 * time.Second) - for time.Now().Before(deadline) { - c, err := loadCache() - if err == nil && c != nil { - if c.Status == "failed" { - t.Fatalf("background update failed: %s", c.Error) - } - if c.Status == "updated" { - if c.InstalledVersion != "v1.2.3" || !c.NotificationPending { - t.Fatalf("completion state: %#v", c) - } - probe := exec.Command(target, "--version") - probe.Env = append(os.Environ(), "ONE_UPDATE_TEST_INTEGRATION=1") - if raw, err := probe.Output(); err != nil || string(raw) != "1.2.3\n" { - t.Fatalf("installed binary: %q %v", raw, err) - } - return - } - } - time.Sleep(20 * time.Millisecond) - } - c, err := loadCache() - t.Fatalf("update did not complete after the initiating command exited: cache=%#v, error=%v", c, err) -} - -func TestUpdateIntegrationLauncher(t *testing.T) { - if os.Getenv("ONE_UPDATE_TEST_INTEGRATION") != "1" { - return - } - target, err := os.Executable() - if err != nil { - os.Exit(2) - } - startUpdate("1.0.0", target, launchWorker) - os.Exit(0) -} diff --git a/packages/cli/internal/platform/userdirs/home_test.go b/packages/cli/internal/platform/userdirs/home_test.go deleted file mode 100644 index 759fef62..00000000 --- a/packages/cli/internal/platform/userdirs/home_test.go +++ /dev/null @@ -1,18 +0,0 @@ -package userdirs - -import ( - "path/filepath" - "testing" -) - -func TestHomeHonorsExplicitHomeOnEveryPlatform(t *testing.T) { - want := filepath.Join(t.TempDir(), "isolated-home") - t.Setenv("HOME", want) - got, err := Home() - if err != nil { - t.Fatalf("Home: %v", err) - } - if got != want { - t.Fatalf("Home = %q, want %q", got, want) - } -} diff --git a/packages/cli/internal/ports/secrets/envname_test.go b/packages/cli/internal/ports/secrets/envname_test.go deleted file mode 100644 index 6f35b2d2..00000000 --- a/packages/cli/internal/ports/secrets/envname_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package secrets - -import ( - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" -) - -func TestEnvironmentSelectionUsesFixedDefaultAndRemoteSlugs(t *testing.T) { - root := t.TempDir() - m := &workspace.Manifest{Version: 2, Env: &workspace.EnvironmentConfig{ProjectID: "remote", Environments: []string{"prod", "dev", "qa"}}} - if err := workspace.WriteManifest(root, m); err != nil { - t.Fatal(err) - } - for _, tc := range []struct { - flag, want string - allow, invalid bool - }{ - {"", "dev", false, false}, {"qa", "qa", false, false}, {"production", "", false, true}, {"new-env", "new-env", true, false}, - } { - got, _, err := ResolveEnvName(root, tc.flag, tc.allow) - if (err != nil) != tc.invalid || got != tc.want { - t.Errorf("flag %q = %q, %v", tc.flag, got, err) - } - } -} diff --git a/packages/cli/internal/ports/secrets/loader_test.go b/packages/cli/internal/ports/secrets/loader_test.go deleted file mode 100644 index 0ddf3d5c..00000000 --- a/packages/cli/internal/ports/secrets/loader_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package secrets - -import ( - "context" - "errors" - "reflect" - "testing" -) - -type fake struct{ id string } - -func (f fake) ID() string { return f.id } -func (f fake) Load(context.Context, string, string, string) (map[string]string, error) { - return map[string]string{f.id: "ok"}, nil -} - -func TestFind(t *testing.T) { - registry := MustRegistry( - fake{id: "infisical"}, - ) - - if l := registry.Find("infisical"); l == nil || l.ID() != "infisical" { - t.Errorf("Find(infisical) returned %v", l) - } - if l := registry.Find("nope"); l != nil { - t.Errorf("Find for unknown id should return nil, got %v", l) - } -} - -func TestRegistryRejectsDuplicateIDs(t *testing.T) { - if _, err := NewRegistry(fake{id: "infisical"}, fake{id: "infisical"}); err == nil { - t.Fatal("expected duplicate loader IDs to fail") - } -} - -type countingLoader struct { - dirs []string - fail error -} - -func (l *countingLoader) ID() string { return "fixture" } -func (l *countingLoader) Load(_ context.Context, root, dir, env string) (map[string]string, error) { - l.dirs = append(l.dirs, dir) - return map[string]string{"root": root, "dir": dir, "env": env}, l.fail -} -func TestLoadProjectsFallback(t *testing.T) { - l := &countingLoader{} - got, err := LoadProjects(context.Background(), l, "workspace", []string{"apps/a", "apps/b", "apps/a"}, "staging") - if err != nil || len(got) != 2 || !reflect.DeepEqual(l.dirs, []string{"apps/a", "apps/b"}) || got["apps/b"]["env"] != "staging" { - t.Fatalf("%v %v %v", got, l.dirs, err) - } - failure := errors.New("no snapshot") - l = &countingLoader{fail: failure} - got, err = LoadProjects(context.Background(), l, "workspace", []string{"apps/a", "apps/b"}, "dev") - if got != nil || !errors.Is(err, failure) || len(l.dirs) != 1 { - t.Fatal("fallback continued after failure") - } - ctx, cancel := context.WithCancel(context.Background()) - cancel() - got, err = LoadProjects(ctx, l, "workspace", []string{"apps/b"}, "dev") - if got != nil || !errors.Is(err, context.Canceled) || len(l.dirs) != 1 { - t.Fatal("fallback ignored cancellation") - } -} - -// Single-project loaders may reuse their buffer; capture it before the next load. -type reuseLoader struct{ values map[string]string } - -func (l reuseLoader) ID() string { return "reuse" } -func (l reuseLoader) Load(_ context.Context, _, dir, _ string) (map[string]string, error) { - l.values["DIR"] = dir - return l.values, nil -} -func TestLoadProjectsFallbackIsolatesReusedMaps(t *testing.T) { - got, err := LoadProjects(context.Background(), reuseLoader{map[string]string{}}, "workspace", []string{"apps/a", "apps/b"}, "dev") - if err != nil { - t.Fatal(err) - } - if got["apps/a"]["DIR"] != "apps/a" || got["apps/b"]["DIR"] != "apps/b" { - t.Fatal("fallback overwrote an earlier snapshot") - } - got["apps/a"]["DIR"] = "changed" - if got["apps/b"]["DIR"] != "apps/b" { - t.Fatal("fallback project maps alias") - } -} diff --git a/packages/cli/internal/ports/secrets/merge_windows_test.go b/packages/cli/internal/ports/secrets/merge_windows_test.go deleted file mode 100644 index 31bf1d50..00000000 --- a/packages/cli/internal/ports/secrets/merge_windows_test.go +++ /dev/null @@ -1,29 +0,0 @@ -//go:build windows - -package secrets - -import "testing" - -func TestMergeIntoEnvironTreatsPathCaseInsensitivelyOnWindows(t *testing.T) { - got := MergeIntoEnviron( - []string{"Path=C:\\Windows", "KEEP=yes"}, - map[string]string{"PATH": "C:\\tools"}, - true, - ) - var paths []string - for _, entry := range got { - key := entry - for i, char := range entry { - if char == '=' { - key = entry[:i] - break - } - } - if environmentKey(key) == "PATH" { - paths = append(paths, entry) - } - } - if len(paths) != 1 || paths[0] != "PATH=C:\\tools" { - t.Fatalf("Windows PATH merge = %v; full env = %v", paths, got) - } -} diff --git a/packages/cli/internal/resources/bundled/bundled_test.go b/packages/cli/internal/resources/bundled/bundled_test.go deleted file mode 100644 index b1b7e224..00000000 --- a/packages/cli/internal/resources/bundled/bundled_test.go +++ /dev/null @@ -1,272 +0,0 @@ -package bundled_test - -// Bundled-asset invariants. Phase 2 of the test plan: gives `mise run test` -// authority equivalent to `task verify-bundled`'s shell `diff -rq` so a -// developer running `go test ./...` (e.g. via IDE) sees drift too. -// -// Checks: -// - RegistryBytes parses as JSON with the expected top-level shape -// - Each embedded FS counts files and matches the canonical on-disk -// source under the repo root -// - Spot files known to ship are present (registry.json structure and -// at least one template) -// -// If you add a new template and `mise run test` suddenly fails here, -// run `mise run sync-bundled` first. - -import ( - "bytes" - "encoding/json" - "io/fs" - "os" - "path/filepath" - "runtime" - "strings" - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/templatefiles" - "github.com/torchstellar-team/one-cli/packages/cli/internal/resources/bundled" -) - -// repoRoot resolves the monorepo root regardless of test cwd. -func repoRoot(t *testing.T) string { - t.Helper() - _, file, _, ok := runtime.Caller(0) - if !ok { - t.Fatalf("runtime.Caller failed") - } - // packages/cli/internal/resources/bundled/bundled_test.go → five levels up. - return filepath.Join(filepath.Dir(file), "..", "..", "..", "..", "..") -} - -func TestRegistryBytes_ParsesAndHasTemplates(t *testing.T) { - if len(bundled.RegistryBytes) == 0 { - t.Fatal("RegistryBytes is empty — go:embed of registry.json failed") - } - - var registry struct { - Templates []struct { - ID string `json:"id"` - } `json:"templates"` - } - if err := json.Unmarshal(bundled.RegistryBytes, ®istry); err != nil { - t.Fatalf("parse registry.json: %v", err) - } - if len(registry.Templates) == 0 { - t.Fatal("registry.json has no templates") - } - - // go-api is referenced by E2E tests; missing it would silently - // break the smoke pyramid. - wantTemplate := "go-api" - found := false - for _, tpl := range registry.Templates { - if tpl.ID == wantTemplate { - found = true - break - } - } - if !found { - t.Errorf("registry.json missing template %q", wantTemplate) - } -} - -func TestRegistryBytes_MatchesOnDisk(t *testing.T) { - disk, err := os.ReadFile(filepath.Join(repoRoot(t), "packages", "templates", "registry.json")) - if err != nil { - t.Fatalf("read root registry.json: %v", err) - } - if string(disk) != string(bundled.RegistryBytes) { - t.Error("registry.json drift between root and internal/resources/bundled — run 'mise run sync-bundled'") - } -} - -func TestSkillsFS_MatchesOnDisk(t *testing.T) { - embedded := mustWalk(t, bundled.SkillsFS, bundled.SkillsRoot) - diskRoot := filepath.Join(repoRoot(t), "packages", "agent-skills", "skills") - disk := mustWalkOSFiltered(t, diskRoot, nil) - assertSameFiles(t, "skills", embedded, disk) - for _, name := range disk { - want, err := os.ReadFile(filepath.Join(diskRoot, filepath.FromSlash(name))) - if err != nil { - t.Fatal(err) - } - got, err := fs.ReadFile(bundled.SkillsFS, bundled.SkillsRoot+"/"+name) - if err != nil || !bytes.Equal(got, want) { - t.Errorf("skill differs: %s: %v", name, err) - } - } -} - -// WebDistFS is special: its content is a build artefact (Vite bundle), not -// a copy of source files. Vite chunk hashes vary per machine, so we don't -// byte-compare against a fresh build like the template tests do. -// Instead we assert the embed structure looks like a real Vite dist: -// index.html present and at least one hashed JS asset under assets/. -func TestWebDistFS_HasViteDist(t *testing.T) { - files := mustWalk(t, bundled.WebDistFS, bundled.WebDistRoot) - if !contains(files, "index.html") { - t.Errorf("WebDistFS missing index.html — run 'mise run sync-bundled'") - } - hasJSAsset := false - for _, f := range files { - if strings.HasPrefix(f, "assets/") && strings.HasSuffix(f, ".js") { - hasJSAsset = true - break - } - } - if !hasJSAsset { - t.Errorf("WebDistFS has no assets/*.js — run 'mise run sync-bundled'") - } -} - -func TestTemplatesFS_MatchesOnDisk(t *testing.T) { - embedded := mustWalk(t, bundled.TemplatesFS, bundled.TemplatesRoot) - for _, name := range embedded { - if isAgentAsset(name) { - t.Errorf("retired agent asset embedded in binary: %s", name) - } - } - diskRoot := filepath.Join(repoRoot(t), "packages", "templates") - disk := mustWalkOSFiltered(t, diskRoot, func(rel string) bool { - return rel == "registry.json" || templatefiles.Excluded(rel) - }) - for i, name := range disk { - packed := templatefiles.PackPath(name) - original, err := os.ReadFile(filepath.Join(diskRoot, filepath.FromSlash(name))) - if err != nil { - t.Fatal(err) - } - actual, err := fs.ReadFile(bundled.TemplatesFS, bundled.TemplatesRoot+"/"+packed) - if err != nil || !bytes.Equal(actual, original) { - t.Errorf("bundled content differs for %s: %v", name, err) - } - disk[i] = packed - } - - assertSameFiles(t, "templates", embedded, disk) - - // At least one known template must ship. - if !hasPrefix(embedded, "go-api/") { - t.Error("TemplatesFS missing go-api template") - } -} - -func isAgentAsset(path string) bool { - for _, part := range strings.Split(filepath.ToSlash(path), "/") { - switch part { - case ".one", ".agents", "AGENTS.md", "CLAUDE.md", "SKILL.md": - return true - } - } - return false -} - -// mustWalk returns the sorted list of file paths inside fsys rooted at -// root, with the root prefix stripped so paths align with mustWalkOS. -func mustWalk(t *testing.T, fsys fs.FS, root string) []string { - t.Helper() - var out []string - err := fs.WalkDir(fsys, root, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if d.IsDir() { - return nil - } - rel := strings.TrimPrefix(path, root+"/") - out = append(out, rel) - return nil - }) - if err != nil { - t.Fatalf("walk fs %s: %v", root, err) - } - return out -} - -func mustWalkOSFiltered(t *testing.T, root string, skip func(rel string) bool) []string { - t.Helper() - var out []string - err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - rel, _ := filepath.Rel(root, path) - if d.IsDir() { - if skip != nil && skip(filepath.ToSlash(rel)) { - return filepath.SkipDir - } - return nil - } - // Normalise to forward slashes — embed.FS always uses '/'. - rel = filepath.ToSlash(rel) - if skip != nil && skip(rel) { - return nil - } - out = append(out, rel) - return nil - }) - if err != nil { - t.Fatalf("walk disk %s: %v", root, err) - } - return out -} - -// assertSameFiles compares two file lists as sets and reports drift in a -// way that points at `mise run sync-bundled`. -func assertSameFiles(t *testing.T, label string, embedded, disk []string) { - t.Helper() - t.Logf("%s: embedded=%d disk=%d", label, len(embedded), len(disk)) - a := toSet(embedded) - b := toSet(disk) - - var onlyEmbed, onlyDisk []string - for f := range a { - if !b[f] { - onlyEmbed = append(onlyEmbed, f) - } - } - for f := range b { - if !a[f] { - onlyDisk = append(onlyDisk, f) - } - } - if len(onlyEmbed) > 0 || len(onlyDisk) > 0 { - // Cap the output to avoid drowning the log. - const cap = 5 - if len(onlyEmbed) > cap { - onlyEmbed = append(onlyEmbed[:cap], "...") - } - if len(onlyDisk) > cap { - onlyDisk = append(onlyDisk[:cap], "...") - } - t.Errorf("%s drift between bundled and root — run 'mise run sync-bundled'\n only in bundled: %v\n only on disk: %v", - label, onlyEmbed, onlyDisk) - } -} - -func toSet(xs []string) map[string]bool { - s := make(map[string]bool, len(xs)) - for _, x := range xs { - s[x] = true - } - return s -} - -func contains(xs []string, want string) bool { - for _, x := range xs { - if x == want { - return true - } - } - return false -} - -func hasPrefix(xs []string, prefix string) bool { - for _, x := range xs { - if strings.HasPrefix(x, prefix) { - return true - } - } - return false -} diff --git a/packages/cli/internal/transport/cobra/add/cmd_test.go b/packages/cli/internal/transport/cobra/add/cmd_test.go deleted file mode 100644 index a2a55e2f..00000000 --- a/packages/cli/internal/transport/cobra/add/cmd_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package addcmd - -import ( - "bytes" - "encoding/json" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" - "strings" - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/template" -) - -func TestProjectKindForUsesDirectoryCategory(t *testing.T) { - tests := []struct { - name string - tpl template.Template - want projectKind - }{ - { - name: "documentation site stays with applications", - tpl: template.Template{ - Category: template.CategoryFrontend, - Tags: []string{"docs"}, - }, - want: kindApplication, - }, - { - name: "backend maps to services", - tpl: template.Template{Category: template.CategoryBackend}, - want: kindService, - }, - { - name: "library maps to packages", - tpl: template.Template{Category: template.CategoryLibrary}, - want: kindLibrary, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := projectKindFor(tt.tpl); got != tt.want { - t.Fatalf("projectKindFor() = %q, want %q", got, tt.want) - } - }) - } -} - -func TestCompositeAddResult(t *testing.T) { - original := i18n.Active() - t.Cleanup(func() { _ = i18n.Init(original) }) - result := addResult{SubprojectName: "desktop", TargetPath: "/workspace", TemplateID: "electron-app", Toolchain: "node", Projects: []addProject{ - {Name: "desktop-renderer", TargetPath: "/workspace/apps/desktop-renderer", Toolchain: "node"}, - {Name: "desktop-main", TargetPath: "/workspace/services/desktop-main", Toolchain: "node"}, - {Name: "desktop-preload", TargetPath: "/workspace/packages/desktop-preload", Toolchain: "node"}, - }} - for _, locale := range []string{"zh-CN", "en-US"} { - _ = i18n.Init(locale) - var out bytes.Buffer - result.RenderTTY(&out) - for _, member := range result.Projects { - if !strings.Contains(out.String(), member.TargetPath) { - t.Fatal(out.String()) - } - } - if !strings.Contains(out.String(), "one run desktop:dev") || strings.Contains(out.String(), "one dev -p desktop") { - t.Fatal(out.String()) - } - if !strings.Contains(out.String(), i18n.Tf("add.group_success", "desktop")) { - t.Fatal(out.String()) - } - } - raw, err := json.Marshal(result) - if err != nil { - t.Fatal(err) - } - var fields map[string]json.RawMessage - if err := json.Unmarshal(raw, &fields); err != nil || len(fields["projects"]) == 0 { - t.Fatalf("%s: %v", raw, err) - } - result.Projects = nil - raw, _ = json.Marshal(result) - if strings.Contains(string(raw), `"projects"`) { - t.Fatal("ordinary output changed") - } -} diff --git a/packages/cli/internal/transport/cobra/create/name_test.go b/packages/cli/internal/transport/cobra/create/name_test.go deleted file mode 100644 index 808da310..00000000 --- a/packages/cli/internal/transport/cobra/create/name_test.go +++ /dev/null @@ -1,20 +0,0 @@ -package createcmd - -import ( - "path/filepath" - "testing" -) - -func TestWorkspaceNameUsesDirectoryUnlessExplicitlyOverridden(t *testing.T) { - for _, tc := range []struct{ path, override, want string }{ - {filepath.Join(t.TempDir(), "demo"), "", "demo"}, - {filepath.Join(t.TempDir(), "projects", "demo") + string(filepath.Separator), "", "demo"}, - {filepath.Join(t.TempDir(), "invalid folder"), "custom-name", "custom-name"}, - {filepath.Join(t.TempDir(), "invalid folder"), "", ""}, - } { - got, err := resolveWorkspaceName(tc.path, tc.override) - if got != tc.want || (err != nil) != (tc.want == "") { - t.Errorf("name(%q,%q)=%q,%v", tc.path, tc.override, got, err) - } - } -} diff --git a/packages/cli/internal/transport/cobra/create/result_test.go b/packages/cli/internal/transport/cobra/create/result_test.go deleted file mode 100644 index 66319bef..00000000 --- a/packages/cli/internal/transport/cobra/create/result_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package createcmd - -import ( - "os" - "path/filepath" - "runtime" - "testing" -) - -func TestRelativeOrAbsWithSymlinkPaths(t *testing.T) { - root, err := filepath.EvalSymlinks(t.TempDir()) - if err != nil { - t.Fatal(err) - } - alias := filepath.Join(t.TempDir(), "parent-link") - if err := os.Symlink(root, alias); err != nil { - if runtime.GOOS == "windows" { - t.Skipf("directory symlinks unavailable: %v", err) - } - t.Fatal(err) - } - if err := os.Mkdir(filepath.Join(root, "existing"), 0o755); err != nil { - t.Fatal(err) - } - for _, tc := range []struct { - name, cwd, target, want string - inPlace bool - }{ - {name: "plain", cwd: root, target: filepath.Join(root, "demo"), want: "demo"}, - {name: "target-alias", cwd: root, target: filepath.Join(alias, "demo"), want: "demo"}, - {name: "cwd-alias", cwd: alias, target: filepath.Join(root, "demo"), want: "demo"}, - {name: "both-aliases", cwd: alias, target: filepath.Join(alias, "demo"), want: "demo"}, - {name: "missing-parents", cwd: root, target: filepath.Join(alias, "new", "nested", "demo"), want: filepath.Join("new", "nested", "demo")}, - {name: "existing-target", cwd: root, target: filepath.Join(alias, "existing"), want: "existing"}, - {name: "in-place", cwd: root, target: alias, inPlace: true, want: "."}, - {name: "sibling", cwd: filepath.Join(root, "existing"), target: filepath.Join(alias, "demo"), want: filepath.Join("..", "demo")}, - } { - t.Run(tc.name, func(t *testing.T) { - if got := relativeOrAbs(tc.cwd, tc.target, tc.inPlace); got != tc.want { - t.Errorf("relativeOrAbs(%q, %q, %v) = %q; want %q", tc.cwd, tc.target, tc.inPlace, got, tc.want) - } - }) - } -} diff --git a/packages/cli/internal/transport/cobra/env/bind_test.go b/packages/cli/internal/transport/cobra/env/bind_test.go deleted file mode 100644 index efeea82c..00000000 --- a/packages/cli/internal/transport/cobra/env/bind_test.go +++ /dev/null @@ -1,156 +0,0 @@ -package envcmd - -import ( - "context" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "os" - "strings" - "testing" - "time" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/application/execution" - catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" - environmentmodule "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/environment" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" - session "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/infisicalsession" - "github.com/zalando/go-keyring" -) - -func workspaceBindFixture(t *testing.T) (Dependencies, execution.Scope) { - t.Helper() - root := t.TempDir() - if err := workspace.WriteManifest(root, &workspace.Manifest{ - Version: workspace.ManifestVersion, Workspace: &workspace.ManifestWorkspace{ID: "workspace", Name: "demo"}, - }); err != nil { - t.Fatal(err) - } - service, err := environmentmodule.NewService(catalog.Builtin()) - if err != nil { - t.Fatal(err) - } - return Dependencies{Service: service}, execution.NewScope(context.Background(), root) -} - -func TestWorkspaceBindExplicitCreateAndExistingProject(t *testing.T) { - for _, test := range []struct { - name string - args []string - wantCreates int - }{ - {"create", []string{"--create"}, 1}, - {"existing project", []string{"--project-id", "remote"}, 0}, - } { - t.Run(test.name, func(t *testing.T) { - keyring.MockInit() - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - creates := 0 - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch r.URL.Path { - case "/api/v2/workspace": - creates++ - var body map[string]string - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Error(err) - } - if r.Method != http.MethodPost || body["projectName"] != "demo" { - t.Errorf("unexpected creation: %s %v", r.Method, body) - } - w.Write([]byte(`{"project":{"id":"remote","name":"demo"}}`)) - case "/api/v1/workspace/remote": - w.Write([]byte(`{"workspace":{"id":"remote","name":"Existing","type":"secret-manager","orgId":"org","environments":[{"slug":"dev"},{"slug":"qa"}]}}`)) - case "/api/v3/secrets/raw": - if r.Method != http.MethodGet || r.URL.Query().Get("workspaceId") != "remote" { - t.Errorf("unexpected verification: %s %s", r.Method, r.URL) - } - w.Write([]byte(`{"secrets":[]}`)) - default: - t.Errorf("binding requested unexpected resource: %s %s", r.Method, r.URL) - http.NotFound(w, r) - } - })) - defer upstream.Close() - raw, err := json.Marshal(session.Session{ - Info: session.Info{SiteURL: upstream.URL, UserID: "user", OrganizationID: "org", ExpiresAt: time.Now().Add(time.Hour)}, Token: "test-token", - }) - if err != nil { - t.Fatal(err) - } - if err := keyring.Set("one-cli.infisical", "session", string(raw)); err != nil { - t.Fatal(err) - } - deps, scope := workspaceBindFixture(t) - for range 2 { - cmd := Commands(deps)[0] - cmd.SetContext(execution.WithScope(context.Background(), scope)) - cmd.SetArgs(append([]string{"bind"}, test.args...)) - if err := cmd.Execute(); err != nil { - t.Fatal(err) - } - } - manifest, err := workspace.ReadManifest(scope.WorkingDirectory()) - if err != nil || manifest.Env == nil || manifest.Env.ProjectID != "remote" || manifest.Env.SiteURL != upstream.URL { - t.Fatalf("binding was not saved: %+v %v", manifest, err) - } - if test.wantCreates == 0 && strings.Join(manifest.Env.Environments, ",") != "dev,qa" { - t.Fatalf("did not use remote environments: %v", manifest.Env.Environments) - } - if creates != test.wantCreates { - t.Fatalf("created %d projects, want %d", creates, test.wantCreates) - } - if location, err := environmentmodule.LoadGlobalLocation(); err != nil || location != nil { - t.Fatalf("workspace binding changed global credentials: %+v %v", location, err) - } - }) - } -} - -func TestUnboundSetFailsBeforePromptInBothLanguages(t *testing.T) { - keyring.MockInit() - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - t.Cleanup(func() { _ = i18n.Init(i18n.DefaultLocale) }) - deps, scope := workspaceBindFixture(t) - before, err := os.ReadFile(workspace.ManifestPath(scope.WorkingDirectory())) - if err != nil { - t.Fatal(err) - } - for _, locale := range []string{"zh-CN", "en-US"} { - if err := i18n.Init(locale); err != nil { - t.Fatal(err) - } - for _, args := range [][]string{{"set", "API_TOKEN"}, {"set", "API_TOKEN=private-value"}} { - cmd := Commands(deps)[0] - cmd.SilenceErrors, cmd.SilenceUsage = true, true - cmd.SetContext(execution.WithScope(context.Background(), scope)) - cmd.SetArgs(args) - err := cmd.Execute() - var coded interface{ ErrorCode() string } - if !errors.As(err, &coded) || coded.ErrorCode() != "INFISICAL_NOT_CONFIGURED" || !strings.Contains(err.Error(), "one env bind") || strings.Contains(err.Error(), "private-value") { - t.Fatalf("unexpected unbound %s error: %v", locale, err) - } - } - } - after, err := os.ReadFile(workspace.ManifestPath(scope.WorkingDirectory())) - if err != nil || string(before) != string(after) { - t.Fatalf("unbound set changed the manifest: %v", err) - } -} - -func TestWorkspaceBindRejectsAmbiguousFlagsBeforeRemoteAccess(t *testing.T) { - for _, args := range [][]string{ - {"bind", "--create", "--project-id", "remote"}, - {"bind", "--create", "--env", "prod"}, - {"bind", "--create", "--path", "/"}, - } { - cmd := Commands(Dependencies{})[0] - cmd.SilenceErrors, cmd.SilenceUsage = true, true - cmd.SetArgs(args) - if err := cmd.Execute(); err == nil { - t.Fatalf("accepted ambiguous binding flags: %v", args) - } - } -} diff --git a/packages/cli/internal/transport/cobra/env/global_test.go b/packages/cli/internal/transport/cobra/env/global_test.go deleted file mode 100644 index 8ffaddd7..00000000 --- a/packages/cli/internal/transport/cobra/env/global_test.go +++ /dev/null @@ -1,247 +0,0 @@ -package envcmd - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "sync/atomic" - "testing" - "time" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/env/infisical" - remote "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/environment" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/helpui" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" - session "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/infisicalsession" - "github.com/zalando/go-keyring" -) - -func TestGlobalBindCreatesReusesAndPreservesStorage(t *testing.T) { - for _, test := range []struct { - name string - args []string - defaultExists bool - savedCustom bool - forbidden bool - wantProject string - wantEnv string - wantCreates int - wantError bool - }{ - {name: "create default", wantProject: "shared", wantEnv: "dev", wantCreates: 1}, - {name: "reuse default", defaultExists: true, wantProject: "shared", wantEnv: "dev"}, - {name: "preserve custom binding", savedCustom: true, wantProject: "custom", wantEnv: "prod"}, - {name: "explicit false create preserves binding", args: []string{"--create=false"}, savedCustom: true, wantProject: "custom", wantEnv: "prod"}, - {name: "create with requested environment", args: []string{"--env", "prod"}, wantProject: "shared", wantEnv: "prod", wantCreates: 1}, - {name: "reuse with requested environment", defaultExists: true, args: []string{"--env", "prod"}, wantProject: "shared", wantEnv: "prod"}, - {name: "change saved environment", savedCustom: true, args: []string{"--env", "dev"}, wantProject: "custom", wantEnv: "dev"}, - {name: "explicit project", args: []string{"--project-id", "custom", "--env", "prod"}, wantProject: "custom", wantEnv: "prod"}, - {name: "invalid environment stays unbound", defaultExists: true, args: []string{"--env", "missing"}, wantError: true}, - {name: "invalid environment preserves binding", savedCustom: true, args: []string{"--env", "missing"}, wantProject: "custom", wantEnv: "prod", wantError: true}, - {name: "creation permission denied", forbidden: true, wantCreates: 1, wantError: true}, - } { - t.Run(test.name, func(t *testing.T) { - keyring.MockInit() - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - creates, lists := 0, 0 - shared := remote.RemoteProject{ - ID: "shared", Name: "shared-credentials", Type: "secret-manager", OrganizationID: "org", - Environments: []infisical.RemoteEnvironment{{Slug: "dev"}, {Slug: "prod"}}, - } - custom := shared - custom.ID, custom.Name = "custom", "Custom" - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if r.Header.Get("Authorization") != "Bearer test-token" { - t.Error("request did not use the signed-in session") - } - switch r.URL.Path { - case "/api/v1/workspace": - lists++ - projects := []remote.RemoteProject{custom} - if test.defaultExists { - projects = append(projects, shared) - } - json.NewEncoder(w).Encode(map[string]any{"workspaces": projects}) - case "/api/v2/workspace": - creates++ - if test.forbidden { - http.Error(w, "private-upstream-information", http.StatusForbidden) - return - } - var body map[string]string - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - t.Error(err) - return - } - if r.Method != http.MethodPost || body["projectName"] != shared.Name || body["type"] != shared.Type { - t.Errorf("unexpected project creation: %s %v", r.Method, body) - } - json.NewEncoder(w).Encode(map[string]any{"project": shared}) - case "/api/v1/workspace/shared": - json.NewEncoder(w).Encode(map[string]any{"workspace": shared}) - case "/api/v1/workspace/custom": - json.NewEncoder(w).Encode(map[string]any{"workspace": custom}) - default: - t.Errorf("unexpected request: %s %s", r.Method, r.URL) - http.NotFound(w, r) - } - })) - defer upstream.Close() - raw, err := json.Marshal(session.Session{ - Info: session.Info{SiteURL: upstream.URL, UserID: "user", OrganizationID: "org", ExpiresAt: time.Now().Add(time.Hour)}, - Token: "test-token", - }) - if err != nil { - t.Fatal(err) - } - if err := keyring.Set("one-cli.infisical", "session", string(raw)); err != nil { - t.Fatal(err) - } - if test.savedCustom { - if _, err := remote.BindGlobal(context.Background(), "custom", "prod"); err != nil { - t.Fatal(err) - } - } - for range 2 { - cmd := Commands(Dependencies{})[0] - cmd.SilenceUsage, cmd.SilenceErrors = true, true - cmd.SetArgs(append([]string{"bind", "--global"}, test.args...)) - err := cmd.Execute() - if (err != nil) != test.wantError { - t.Fatalf("bind error = %v, wantError = %v", err, test.wantError) - } - if test.forbidden { - var coded interface{ ErrorCode() string } - if !errors.As(err, &coded) || coded.ErrorCode() != "INFISICAL_PROJECT_CREATE_FORBIDDEN" || strings.Contains(err.Error(), "private-upstream-information") { - t.Fatalf("creation failure lost its error code or leaked upstream data: %v", err) - } - } - location, err := remote.LoadGlobalLocation() - if err != nil { - t.Fatal(err) - } - if test.wantProject == "" { - if location != nil { - t.Fatalf("saved binding after failed setup: %+v", location) - } - } else if location == nil || location.ProjectID != test.wantProject || location.DefaultEnvironment != test.wantEnv || location.UserID != "user" || location.OrganizationID != "org" || location.SiteURL != upstream.URL { - t.Fatalf("unexpected saved binding: %+v", location) - } - if test.wantError { - break - } - } - if creates != test.wantCreates { - t.Fatalf("created %d projects, want %d", creates, test.wantCreates) - } - if (test.savedCustom || test.wantProject == "custom") && lists != 0 { - t.Fatal("saved or explicit binding searched for the default project") - } - }) - } -} - -func TestGlobalBindRejectsCreateBeforeRemoteAccessInBothLanguages(t *testing.T) { - t.Cleanup(func() { _ = i18n.Init(i18n.DefaultLocale) }) - for _, locale := range []string{"zh-CN", "en-US"} { - for _, loggedIn := range []bool{false, true} { - t.Run(locale+"/"+map[bool]string{false: "logged out", true: "logged in"}[loggedIn], func(t *testing.T) { - keyring.MockInit() - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - if err := i18n.Init(locale); err != nil { - t.Fatal(err) - } - var requests atomic.Int32 - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requests.Add(1) - http.Error(w, "unexpected remote access", http.StatusInternalServerError) - })) - defer upstream.Close() - if loggedIn { - raw, err := json.Marshal(session.Session{ - Info: session.Info{SiteURL: upstream.URL, UserID: "user", OrganizationID: "org", ExpiresAt: time.Now().Add(time.Hour)}, Token: "test-token", - }) - if err != nil { - t.Fatal(err) - } - if err := keyring.Set("one-cli.infisical", "session", string(raw)); err != nil { - t.Fatal(err) - } - } - path, err := session.ConfigPath("global-env.json") - if err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { - t.Fatal(err) - } - before := []byte(`{"siteUrl":"saved-site","projectId":"saved-project","defaultEnvironment":"prod"}`) - if err := os.WriteFile(path, before, 0600); err != nil { - t.Fatal(err) - } - cmd := Commands(Dependencies{})[0] - cmd.SilenceErrors, cmd.SilenceUsage = true, true - cmd.SetArgs([]string{"bind", "--global", "--create"}) - err = cmd.Execute() - if err == nil || err.Error() != i18n.T("env.bind.create_workspace_only") || !strings.Contains(err.Error(), "one env bind --global") { - t.Fatalf("invalid flags did not return the localized recovery command: %v", err) - } - after, err := os.ReadFile(path) - if err != nil || !bytes.Equal(before, after) || requests.Load() != 0 { - t.Fatalf("invalid flags contacted Infisical or changed the binding: %v", err) - } - }) - } - } -} - -func TestGlobalBindRequiresLogin(t *testing.T) { - keyring.MockInit() - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - for _, args := range [][]string{{"bind", "--global"}} { - cmd := Commands(Dependencies{})[0] - cmd.SilenceUsage, cmd.SilenceErrors = true, true - cmd.SetArgs(args) - if err := cmd.Execute(); err == nil { - t.Fatalf("accepted binding without scope or login: %v", args) - } - if location, err := remote.LoadGlobalLocation(); err != nil || location != nil { - t.Fatalf("created a binding without scope or login: %+v %v", location, err) - } - } -} - -func TestGlobalBindHelpRefreshesInBothLanguages(t *testing.T) { - t.Cleanup(func() { _ = i18n.Init(i18n.DefaultLocale) }) - cmd := Commands(Dependencies{})[0] - cmd.SetHelpFunc(helpui.Render) - bind, _, err := cmd.Find([]string{"bind"}) - if err != nil { - t.Fatal(err) - } - for _, test := range []struct{ locale, want, absent string }{ - {"en-US", "No project selection or ID is required", "无需选择项目或填写 ID"}, - {"zh-CN", "无需选择项目或填写 ID", "No project selection or ID is required"}, - {"en-US", "No project selection or ID is required", "无需选择项目或填写 ID"}, - } { - if err := i18n.Init(test.locale); err != nil { - t.Fatal(err) - } - i18n.RefreshTree(cmd) - var out bytes.Buffer - bind.SetOut(&out) - if err := bind.Help(); err != nil { - t.Fatal(err) - } - if !strings.Contains(out.String(), test.want) || strings.Contains(out.String(), test.absent) || strings.Contains(out.String(), "env.bind.") { - t.Fatalf("help did not refresh to %s: %s", test.locale, out.String()) - } - } -} diff --git a/packages/cli/internal/transport/cobra/env/input_test.go b/packages/cli/internal/transport/cobra/env/input_test.go deleted file mode 100644 index 288ada5a..00000000 --- a/packages/cli/internal/transport/cobra/env/input_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package envcmd - -import ( - "errors" - "strings" - "testing" - - "github.com/spf13/cobra" -) - -func TestReadSetValuePreservesMultilineInput(t *testing.T) { - t.Parallel() - for _, test := range []struct{ name, input, want string }{ - {"LF", "-----BEGIN PRIVATE KEY-----\nfixture\n-----END PRIVATE KEY-----\n", "-----BEGIN PRIVATE KEY-----\nfixture\n-----END PRIVATE KEY-----"}, - {"CRLF", "header\r\nbody\r\n", "header\r\nbody"}, - {"two trailing lines", "body\n\n", "body\n"}, - {"literal escape", "header\\nbody", "header\\nbody"}, - {"empty", "", ""}, - {"limit", strings.Repeat("a", 1<<20), strings.Repeat("a", 1<<20)}, - } { - t.Run(test.name, func(t *testing.T) { - t.Parallel() - cmd := &cobra.Command{} - cmd.Flags().Bool("stdin", true, "") - cmd.SetIn(strings.NewReader(test.input)) - got, provided, err := readSetValue(cmd, []string{"PRIVATE_KEY"}) - if err != nil || !provided || got != test.want { - t.Fatalf("stdin value was not preserved (provided=%v, error=%v)", provided, err) - } - }) - } -} - -func TestSetRejectsStdinConflictAndOversizeBeforeRemoteAccess(t *testing.T) { - for _, global := range []bool{false, true} { - for _, test := range []struct { - args []string - input string - }{ - {[]string{"PRIVATE_KEY", "private-argument-marker", "--stdin"}, ""}, - {[]string{"PRIVATE_KEY=private-argument-marker", "--stdin"}, ""}, - {[]string{"PRIVATE_KEY", "--stdin"}, strings.Repeat("private-input-marker", 60_000)}, - } { - // A nil service proves these failures cannot reach project planning or writes. - cmd := Commands(Dependencies{})[0] - cmd.SilenceUsage = true - cmd.SilenceErrors = true - args := append([]string{"set"}, test.args...) - if global { - args = append(args, "--global") - } - cmd.SetArgs(args) - cmd.SetIn(strings.NewReader(test.input)) - err := cmd.Execute() - if err == nil || strings.Contains(err.Error(), "private-argument-marker") || strings.Contains(err.Error(), "private-input-marker") { - t.Fatalf("unsafe stdin rejection (global=%v): %v", global, err) - } - } - } -} - -type brokenInput struct{ err error } - -func (input brokenInput) Read([]byte) (int, error) { return 0, input.err } - -func TestReadSetValuePreservesReadErrorAndExplicitValueBehavior(t *testing.T) { - cmd := &cobra.Command{} - cmd.Flags().Bool("stdin", true, "") - want := errors.New("stdin transport failed") - cmd.SetIn(brokenInput{want}) - if _, _, err := readSetValue(cmd, []string{"TOKEN"}); !errors.Is(err, want) { - t.Fatalf("lost stdin error: %v", err) - } - if err := cmd.Flags().Set("stdin", "false"); err != nil { - t.Fatal(err) - } - value, provided, err := readSetValue(cmd, []string{"TOKEN="}) - if err != nil || !provided || value != "" { - t.Fatal("explicit empty values changed") - } - _, provided, err = readSetValue(cmd, []string{"TOKEN"}) - if err != nil || provided { - t.Fatal("hidden-prompt selection changed") - } -} diff --git a/packages/cli/internal/transport/cobra/env/render_test.go b/packages/cli/internal/transport/cobra/env/render_test.go deleted file mode 100644 index 24562fa6..00000000 --- a/packages/cli/internal/transport/cobra/env/render_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package envcmd - -import ( - "bytes" - "encoding/json" - "strings" - "testing" - - environmentmodule "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/environment" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" -) - -func TestGlobalBindResultShowsStorageInBothLanguagesAndPreservesJSON(t *testing.T) { - t.Cleanup(func() { _ = i18n.Init(i18n.DefaultLocale) }) - location := &environmentmodule.GlobalLocation{ - SiteURL: "https://secrets.example.com", UserID: "user", OrganizationID: "org", - ProjectID: "shared-project", ProjectName: "shared-credentials", DefaultEnvironment: "dev", - } - result := bindOutput{location} - for _, test := range []struct{ locale, want string }{ - {"zh-CN", "✓ 已绑定共享凭据项目:shared-credentials(shared-project),默认环境:dev。\n"}, - {"en-US", "✓ Bound shared credential project: shared-credentials (shared-project), default environment: dev.\n"}, - } { - if err := i18n.Init(test.locale); err != nil { - t.Fatal(err) - } - var out bytes.Buffer - result.RenderTTY(&out) - if out.String() != test.want { - t.Fatalf("%s binding output = %q, want %q", test.locale, out.String(), test.want) - } - } - want, err := json.Marshal(location) - if err != nil { - t.Fatal(err) - } - got, err := json.Marshal(result) - if err != nil || !bytes.Equal(got, want) { - t.Fatalf("binding JSON changed: %s, error: %v", got, err) - } -} - -func TestSetResultIdentifiesActualRemoteProjectInBothLanguages(t *testing.T) { - t.Cleanup(func() { _ = i18n.Init(i18n.DefaultLocale) }) - for _, locale := range []string{"zh-CN", "en-US"} { - _ = i18n.Init(locale) - var out bytes.Buffer - result := setOutput{&environmentmodule.SetResult{Key: "API_TOKEN", Environment: "dev", Path: "/", Binding: &environmentmodule.BindingResult{ProjectID: "remote-new", ProjectName: "demo-a3f2", Created: true, RequestedName: "demo"}}} - result.RenderTTY(&out) - for _, want := range []string{"demo-a3f2", "remote-new", "API_TOKEN", "dev"} { - if !strings.Contains(out.String(), want) { - t.Fatalf("%s missing %q: %s", locale, want, out.String()) - } - } - if strings.Contains(out.String(), "env.project_") { - t.Fatalf("unresolved translation: %s", out.String()) - } - } -} diff --git a/packages/cli/internal/transport/cobra/env/set_test.go b/packages/cli/internal/transport/cobra/env/set_test.go deleted file mode 100644 index c6ec992d..00000000 --- a/packages/cli/internal/transport/cobra/env/set_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package envcmd - -import ( - "github.com/spf13/cobra" - "strings" - "testing" -) - -func TestParseSetArgs(t *testing.T) { - t.Parallel() - for _, test := range []struct { - name string - args []string - wantKey string - wantValue string - provided bool - }{ - {name: "two arguments", args: []string{"TOKEN", "value"}, wantKey: "TOKEN", wantValue: "value", provided: true}, - {name: "equals form", args: []string{"TOKEN=a=b"}, wantKey: "TOKEN", wantValue: "a=b", provided: true}, - {name: "missing key", args: []string{"=private-value"}, wantValue: "private-value", provided: true}, - {name: "prompt form", args: []string{"TOKEN"}, wantKey: "TOKEN"}, - } { - t.Run(test.name, func(t *testing.T) { - t.Parallel() - key, value := parseSetArgs(test.args) - if key != test.wantKey || value != test.wantValue { - t.Fatalf("parseSetArgs() = (%q, %q), want (%q, %q)", key, value, test.wantKey, test.wantValue) - } - if got := setValueProvided(test.args); got != test.provided { - t.Fatalf("setValueProvided() = %v, want %v", got, test.provided) - } - }) - } -} - -func TestSetSyntaxErrorsNeverEchoValues(t *testing.T) { - for _, args := range [][]string{{"TOKEN=private-value", "extra-value"}, {"=private-value"}, {"=private-value", "extra-value"}} { - err := validateSetArgs(&cobra.Command{Use: "set"}, args) - if err == nil || strings.Contains(err.Error(), "private-value") || strings.Contains(err.Error(), "extra-value") { - t.Fatalf("unsafe validation: %v", err) - } - } -} diff --git a/packages/cli/internal/transport/cobra/exec/cmd_windows_test.go b/packages/cli/internal/transport/cobra/exec/cmd_windows_test.go deleted file mode 100644 index 0e89c086..00000000 --- a/packages/cli/internal/transport/cobra/exec/cmd_windows_test.go +++ /dev/null @@ -1,33 +0,0 @@ -//go:build windows - -package execcmd - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestAugmentPathForRunReplacesMixedCaseWindowsPath(t *testing.T) { - projectRoot := filepath.Join("C:\\repo", "workspace") - targetDir := filepath.Join(projectRoot, "apps", "web") - got := augmentPathForRun([]string{"Path=C:\\Windows", "KEEP=yes"}, projectRoot, targetDir) - - var paths []string - for _, entry := range got { - key, _, found := strings.Cut(entry, "=") - if found && isPathEnvironmentKey(key) { - paths = append(paths, entry) - } - } - if len(paths) != 1 { - t.Fatalf("PATH entries = %v; full env = %v", paths, got) - } - wantPrefix := "PATH=" + filepath.Join(targetDir, "node_modules", ".bin") + - string(os.PathListSeparator) + filepath.Join(projectRoot, "node_modules", ".bin") + - string(os.PathListSeparator) - if !strings.HasPrefix(paths[0], wantPrefix) || !strings.HasSuffix(paths[0], "C:\\Windows") { - t.Fatalf("augmented PATH = %q, want prefix %q and inherited suffix", paths[0], wantPrefix) - } -} diff --git a/packages/cli/internal/transport/cobra/exec/global_test.go b/packages/cli/internal/transport/cobra/exec/global_test.go deleted file mode 100644 index a05883d2..00000000 --- a/packages/cli/internal/transport/cobra/exec/global_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package execcmd - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" -) - -func TestGlobalCommandEnvRejectsRepositoryAndRelativePATH(t *testing.T) { - root := t.TempDir() - binDir := filepath.Join(root, "bin") - env := globalCommandEnv([]string{"OTHER=keep", "PATH=" + strings.Join([]string{".", "relative", filepath.Join(root, "node_modules", ".bin"), binDir}, string(os.PathListSeparator))}) - if strings.Join(env, "\n") != "OTHER=keep\nPATH="+binDir { - t.Fatal(env) - } - for _, k := range []string{"PATH", "LD_PRELOAD", "NODE_OPTIONS", "GIT_CONFIG_COUNT"} { - if !reservedGlobalKey(k) { - t.Fatal(k) - } - } - if reservedGlobalKey("OSS_ACCESS_KEY_ID") { - t.Fatal("blocked business credential") - } -} -func TestGlobalRunRequiresExplicitScopeBeforeAuthentication(t *testing.T) { - for _, flags := range []*runFlags{{global: true, envName: "prod"}, {global: true, globalPath: "/docker"}, {global: true, envName: "prod", globalPath: "/docker", project: "web"}} { - if e := runGlobal(context.Background(), flags, []string{"echo"}); e == nil { - t.Fatal("accepted ambiguous scope") - } - } -} -func TestGlobalDryRunDoesNotRequireLogin(t *testing.T) { - e := runGlobal(context.Background(), &runFlags{global: true, envName: "prod", globalPath: "/docker", dryRun: true}, []string{os.Args[0]}) - if e != nil { - t.Fatal(e) - } -} diff --git a/packages/cli/internal/transport/cobra/exec/secrets_test.go b/packages/cli/internal/transport/cobra/exec/secrets_test.go deleted file mode 100644 index b131029f..00000000 --- a/packages/cli/internal/transport/cobra/exec/secrets_test.go +++ /dev/null @@ -1,125 +0,0 @@ -package execcmd - -import ( - "context" - "errors" - "os" - "path/filepath" - "runtime" - "strings" - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/application/execution" - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" - platformprocess "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/process" - runtimeport "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/runtime" - "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/secrets" -) - -type recordingLoader struct { - calls int - err error -} - -func (l *recordingLoader) ID() string { return "infisical" } -func (l *recordingLoader) Load(context.Context, string, string, string) (map[string]string, error) { - l.calls++ - return map[string]string{"TOKEN": "remote"}, l.err -} - -func TestSecretsUseOnlyExplicitRemoteBinding(t *testing.T) { - root := t.TempDir() - if err := os.WriteFile(filepath.Join(root, ".env"), []byte("TOKEN=local\n"), 0600); err != nil { - t.Fatal(err) - } - for _, tc := range []struct { - name string - env *workspace.EnvironmentConfig - disabled bool - calls int - }{ - {name: "unconfigured"}, - {name: "configured", env: &workspace.EnvironmentConfig{ProjectID: "remote", Environments: []string{"dev", "staging", "prod"}}, calls: 1}, - } { - t.Run(tc.name, func(t *testing.T) { - loader := &recordingLoader{} - m := &workspace.Manifest{Env: tc.env, Projects: []workspace.ManifestProject{{Name: "web", RelativeDir: "apps/web", Toolchain: "node"}}} - vars, _, err := loadRunSecrets(context.Background(), secrets.MustRegistry(loader), &runFlags{}, root, m, "apps/web") - if err != nil || loader.calls != tc.calls { - t.Fatalf("calls=%d err=%v", loader.calls, err) - } - if tc.calls == 0 && len(vars) != 0 { - t.Fatalf("loaded local variables: %v", vars) - } - if tc.calls == 1 && vars["TOKEN"] != "remote" { - t.Fatal("remote variables missing") - } - }) - } -} -func TestRemoteFailureDoesNotFallBackToLocalFiles(t *testing.T) { - root := t.TempDir() - if err := os.WriteFile(filepath.Join(root, ".env"), []byte("TOKEN=local\n"), 0600); err != nil { - t.Fatal(err) - } - failure := errors.New("remote authentication failed") - loader := &recordingLoader{err: failure} - vars, _, err := loadRunSecrets(context.Background(), secrets.MustRegistry(loader), &runFlags{}, root, &workspace.Manifest{Env: &workspace.EnvironmentConfig{ProjectID: "remote", Environments: []string{"dev", "staging", "prod"}}}, "") - if !errors.Is(err, failure) || vars != nil { - t.Fatalf("vars=%v err=%v", vars, err) - } -} - -func TestWorkspaceExecPreservesChildOutput(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("shell fixture") - } - root := t.TempDir() - manifest := `version = 2 - -[workspace] -id = "test" -name = "test" - -[env.infisical] -projectId = "remote" -environments = ["dev"] - -[projects."web"] -path = "apps/web" -toolchain = "node" -` - if err := os.WriteFile(filepath.Join(root, "one.manifest.toml"), []byte(manifest), 0600); err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(filepath.Join(root, "apps/web"), 0755); err != nil { - t.Fatal(err) - } - out, err := os.CreateTemp(t.TempDir(), "stdout") - if err != nil { - t.Fatal(err) - } - defer out.Close() - errOut, err := os.CreateTemp(t.TempDir(), "stderr") - if err != nil { - t.Fatal(err) - } - defer errOut.Close() - oldOut, oldErr := os.Stdout, os.Stderr - os.Stdout, os.Stderr = out, errOut - defer func() { os.Stdout, os.Stderr = oldOut, oldErr }() - ctx := execution.WithScope(context.Background(), execution.NewScope(context.Background(), root)) - err = runRun(ctx, secrets.MustRegistry(&recordingLoader{}), &runFlags{project: "web", runtime: runtimeport.Builtin}, []string{"sh", "-c", `printf '%s\n' "$TOKEN"; printf '%s\n' "$TOKEN" >&2; exit 6`}) - if platformprocess.ExitCode(err) != 6 { - t.Fatal(err) - } - for _, path := range []string{out.Name(), errOut.Name()} { - log, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(log), "remote") || strings.Contains(string(log), "[REDACTED]") { - t.Fatalf("changed child output: %q", log) - } - } -} diff --git a/packages/cli/internal/transport/cobra/hooks/cmd_test.go b/packages/cli/internal/transport/cobra/hooks/cmd_test.go deleted file mode 100644 index c94c8f43..00000000 --- a/packages/cli/internal/transport/cobra/hooks/cmd_test.go +++ /dev/null @@ -1,142 +0,0 @@ -package hookscmd - -import ( - "bytes" - "context" - "fmt" - "os" - "path/filepath" - "runtime" - "strings" - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" - platformprocess "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/process" - runtimeport "github.com/torchstellar-team/one-cli/packages/cli/internal/ports/runtime" -) - -type recordingProvider struct { - command runtimeport.Command - exitCode int -} - -func (p *recordingProvider) Prepare(context.Context, runtimeport.Command) (runtimeport.Command, error) { - panic("hk should use the runtime CLI entry") -} -func (p *recordingProvider) PrepareCLI(_ context.Context, c runtimeport.Command) (runtimeport.Command, error) { - p.command = c - c.Argv = []string{"sh", "-c", fmt.Sprintf("printf hk-output; printf hk-error >&2; exit %d", p.exitCode)} - return c, nil -} - -func TestHKForwardsArgumentsAndExitStatus(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("POSIX fake process") - } - root := t.TempDir() - t.Chdir(root) - if err := os.WriteFile(filepath.Join(root, ".env"), []byte("ONE_HOOK_SECRET=must-not-load\n"), 0o644); err != nil { - t.Fatal(err) - } - p := &recordingProvider{exitCode: 37} - cmd := Commands(p)[0] - cmd.SilenceUsage, cmd.SilenceErrors = true, true - var out bytes.Buffer - cmd.SetOut(&out) - args := []string{"run", "commit-msg", "a b'$(literal).txt"} - cmd.SetArgs(args) - err := cmd.Execute() - status, ok := err.(*platformprocess.ExitStatus) - if !ok || status.Code != 37 { - t.Fatalf("exit: %#v", err) - } - if out.String() != "hk-output" { - t.Fatalf("stdout: %s", out.String()) - } - want := []string{"exec", workspace.HKTool + "@" + workspace.HKVersion, "--", "hk"} - want = append(want, args...) - if strings.Join(p.command.Argv, "\x00") != strings.Join(want, "\x00") { - t.Fatalf("argv: %#v", p.command.Argv) - } - for _, item := range p.command.Env { - if strings.HasPrefix(item, "ONE_HOOK_SECRET=") { - t.Fatal("loaded app secrets") - } - } -} - -func TestHKCheckFailureShowsWorkspaceRepairCommand(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("POSIX fake process") - } - t.Chdir(t.TempDir()) - for _, tc := range []struct { - name string - args []string - code int - wantHint bool - }{ - {"pre-commit", []string{"run", "pre-commit"}, 37, true}, - {"check", []string{"check", "--all"}, 1, true}, - {"successful check", []string{"check"}, 0, false}, - {"commit message", []string{"run", "commit-msg", "message.txt"}, 1, false}, - {"fix", []string{"fix"}, 1, false}, - {"interrupted", []string{"check"}, 130, false}, - {"terminated", []string{"run", "pre-commit"}, 143, false}, - } { - t.Run(tc.name, func(t *testing.T) { - cmd := Commands(&recordingProvider{exitCode: tc.code})[0] - cmd.SilenceUsage, cmd.SilenceErrors = true, true - var stdout, stderr bytes.Buffer - cmd.SetOut(&stdout) - cmd.SetErr(&stderr) - cmd.SetArgs(tc.args) - err := cmd.Execute() - if tc.code == 0 { - if err != nil { - t.Fatal(err) - } - } else if status, ok := err.(*platformprocess.ExitStatus); !ok || status.Code != tc.code { - t.Fatalf("exit status changed: %v", err) - } - if stdout.String() != "hk-output" { - t.Fatalf("stdout changed: %q", stdout.String()) - } - if !strings.HasPrefix(stderr.String(), "hk-error") || !tc.wantHint && stderr.String() != "hk-error" { - t.Fatalf("child stderr changed: %q", stderr.String()) - } - if got := strings.Contains(stderr.String(), "one hk fix"); got != tc.wantHint { - t.Fatalf("repair hint = %t, want %t: %s", got, tc.wantHint, stderr.String()) - } - if tc.wantHint && (!strings.Contains(stderr.String(), "workspace root") || !strings.Contains(stderr.String(), "stage")) { - t.Fatalf("missing directory/staging guidance: %s", stderr.String()) - } - }) - } -} - -func TestGofmtAdapterRejectsUnformattedAndInvalidInput(t *testing.T) { - root := t.TempDir() - path := filepath.Join(root, "sample.go") - for _, tc := range []struct { - code string - valid bool - }{{"package sample\n", true}, {"package sample;func f(){ }\n", false}, {"not Go syntax\n", false}} { - if err := os.WriteFile(path, []byte(tc.code), 0o644); err != nil { - t.Fatal(err) - } - cmd := gofmtCommand() - var out bytes.Buffer - cmd.SetOut(&out) - cmd.SetErr(&out) - cmd.SetArgs([]string{"--", path}) - err := cmd.Execute() - if (err == nil) != tc.valid { - t.Fatalf("input %q: %v %s", tc.code, err, out.String()) - } - after, _ := os.ReadFile(path) - if string(after) != tc.code { - t.Fatal("check modified source") - } - } -} diff --git a/packages/cli/internal/transport/cobra/serve/cmd_test.go b/packages/cli/internal/transport/cobra/serve/cmd_test.go deleted file mode 100644 index d227850e..00000000 --- a/packages/cli/internal/transport/cobra/serve/cmd_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package servecmd - -import ( - "context" - "os" - "path/filepath" - "testing" - - registrylocal "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/workspaceregistry/local" - workspaceapp "github.com/torchstellar-team/one-cli/packages/cli/internal/application/workspace" - workspacecore "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" -) - -func TestDiscoverServeWorkspaceObservesAncestorManifest(t *testing.T) { - root := t.TempDir() - child := filepath.Join(root, "apps", "web") - if err := workspacecore.WriteManifest(root, &workspacecore.Manifest{ - Version: workspacecore.ManifestVersion, - Workspace: &workspacecore.ManifestWorkspace{ - ID: "serve-test-a1b2c3", Name: "serve-test", - }, - }); err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(child, 0o755); err != nil { - t.Fatal(err) - } - - store := registrylocal.NewAt(filepath.Join(t.TempDir(), "workspaces.json")) - registry, err := workspaceapp.NewRegistryService(store) - if err != nil { - t.Fatal(err) - } - - discovered, err := discoverServeWorkspace(context.Background(), child, registry) - if err != nil { - t.Fatalf("discoverServeWorkspace() error = %v", err) - } - if discovered != root { - t.Fatalf("discovered root = %q, want %q", discovered, root) - } - persisted, err := store.Load(context.Background()) - if err != nil { - t.Fatal(err) - } - canonicalRoot, err := filepath.EvalSymlinks(root) - if err != nil { - t.Fatal(err) - } - if len(persisted.Workspaces) != 1 || persisted.Workspaces[0].Root != canonicalRoot || - persisted.Workspaces[0].LastSeenBy != "serve" { - t.Fatalf("persisted registry = %#v", persisted.Workspaces) - } -} - -func TestDiscoverServeWorkspaceOutsideWorkspaceDoesNotRegister(t *testing.T) { - store := registrylocal.NewAt(filepath.Join(t.TempDir(), "workspaces.json")) - registry, err := workspaceapp.NewRegistryService(store) - if err != nil { - t.Fatal(err) - } - - root, err := discoverServeWorkspace(context.Background(), t.TempDir(), registry) - if err != nil { - t.Fatalf("discoverServeWorkspace() error = %v", err) - } - if root != "" { - t.Fatalf("root = %q, want empty", root) - } - persisted, err := store.Load(context.Background()) - if err != nil { - t.Fatal(err) - } - if len(persisted.Workspaces) != 0 { - t.Fatalf("outside serve registered entries: %#v", persisted.Workspaces) - } -} diff --git a/packages/cli/internal/transport/cobra/skills/cmd_test.go b/packages/cli/internal/transport/cobra/skills/cmd_test.go deleted file mode 100644 index b9692f74..00000000 --- a/packages/cli/internal/transport/cobra/skills/cmd_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package skillscmd - -import ( - "bytes" - "context" - "errors" - "io" - "os" - "reflect" - "testing" - - "github.com/spf13/cobra" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" - platformprocess "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/process" -) - -func TestPassthroughPreservesArgumentsStreamsAndExit(t *testing.T) { - for _, args := range [][]string{nil, {"--help"}, {"add", "owner/repo", "--skill", "a b", "", "$(literal)", "--yes", "--global", "--agent", "codex"}, {"future-command", "--output", "json"}} { - var out, stderr bytes.Buffer - cwd, _ := os.Getwd() - wantExit := &platformprocess.ExitStatus{Code: 37} - called := false - root := &cobra.Command{Use: "one", SilenceErrors: true, SilenceUsage: true} - root.PersistentFlags().String("output", "", "") - root.AddCommand(Commands(func(_ context.Context, dir string, got []string, in io.Reader, stdout, errout io.Writer) error { - called = true - if dir != cwd || !reflect.DeepEqual(append([]string{}, got...), append([]string{}, args...)) { - t.Fatalf("forwarded %s %q; want %s %q", dir, got, cwd, args) - } - _, _ = io.Copy(stdout, in) - _, _ = io.WriteString(errout, "raw tool error\n") - return wantExit - })...) - root.SetIn(bytes.NewBufferString("interactive input\n")) - root.SetOut(&out) - root.SetErr(&stderr) - root.SetArgs(append([]string{"skills"}, args...)) - err := root.Execute() - if !called || !errors.Is(err, wantExit) || out.String() != "interactive input\n" || stderr.String() != "raw tool error\n" { - t.Fatalf("passthrough: called=%v error=%v stdout=%q stderr=%q", called, err, out.String(), stderr.String()) - } - } -} - -func TestHelpRefreshesBothLanguages(t *testing.T) { - defer i18n.Init(i18n.DefaultLocale) - cmd := Commands(nil)[0] - for _, locale := range []string{"zh-CN", "en-US", "zh-CN"} { - _ = i18n.Init(locale) - i18n.RefreshTree(cmd) - if cmd.Short != i18n.T("skills.short") || cmd.Long != i18n.T("skills.tip") { - t.Fatalf("stale help after switching to %s", locale) - } - } -} diff --git a/packages/cli/internal/transport/cobra/upgrade/cmd_test.go b/packages/cli/internal/transport/cobra/upgrade/cmd_test.go deleted file mode 100644 index 61d7ad24..00000000 --- a/packages/cli/internal/transport/cobra/upgrade/cmd_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package upgradecmd - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "os" - "strings" - "testing" - "unicode" - - "github.com/spf13/cobra" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/updatecheck" - "gopkg.in/yaml.v3" -) - -func TestUpgradeEmitsStructuredResults(t *testing.T) { - t.Cleanup(func() { output.SetMode(output.ModeAuto) }) - for _, mode := range []output.Mode{output.ModeJSON, output.ModeYAML} { - t.Run(fmt.Sprint(mode), func(t *testing.T) { - output.SetMode(mode) - stdout, err := os.CreateTemp(t.TempDir(), "stdout") - if err != nil { - t.Fatal(err) - } - defer stdout.Close() - original := os.Stdout - os.Stdout = stdout - defer func() { os.Stdout = original }() - root := &cobra.Command{Use: "one", Version: "1.0.0"} - var stderr bytes.Buffer - root.SetErr(&stderr) - called := false - root.AddCommand(command(func(ctx context.Context, version string) (*updatecheck.Result, error) { - called = true - if version != "1.0.0" || ctx == nil { - t.Fatalf("update arguments: %s %v", version, ctx) - } - return &updatecheck.Result{Schema: "one-cli/upgrade/v1", CurrentVersion: version, - LatestVersion: "v1.2.3", TargetPath: "/test/one", Status: "updated"}, nil - })) - root.SetArgs([]string{"upgrade"}) - if err := root.Execute(); err != nil { - t.Fatal(err) - } - raw, err := os.ReadFile(stdout.Name()) - if err != nil { - t.Fatal(err) - } - var result map[string]any - if mode == output.ModeJSON { - err = json.Unmarshal(raw, &result) - } else { - err = yaml.Unmarshal(raw, &result) - } - if err != nil || !called || stderr.Len() != 0 || result["schema"] != "one-cli/upgrade/v1" || result["status"] != "updated" || result["latest_version"] != "v1.2.3" { - t.Fatalf("structured update: %q, stderr=%q, err=%v", raw, stderr.String(), err) - } - }) - } -} - -func TestUpgradeArgumentValidationAndErrors(t *testing.T) { - output.SetMode(output.ModeJSON) - t.Cleanup(func() { output.SetMode(output.ModeAuto) }) - want := errors.New("network unavailable") - for _, args := range [][]string{{"extra"}, {}} { - called := false - cmd := command(func(context.Context, string) (*updatecheck.Result, error) { - called = true - return nil, want - }) - cmd.SilenceUsage, cmd.SilenceErrors = true, true - cmd.SetArgs(args) - err := cmd.Execute() - if len(args) > 0 { - if called || err == nil { - t.Fatalf("invalid arguments started update: called=%v err=%v", called, err) - } - } else if !called || !errors.Is(err, want) { - t.Fatalf("update error lost: called=%v err=%v", called, err) - } - } -} - -func TestUpgradeHelpAndResultsRefreshBetweenLocales(t *testing.T) { - t.Cleanup(func() { _ = i18n.Init(i18n.DefaultLocale) }) - cmd := Command() - for _, locale := range []string{"en-US", "zh-CN", "en-US"} { - _ = i18n.Init(locale) - i18n.RefreshTree(cmd) - var help bytes.Buffer - cmd.SetOut(&help) - if err := cmd.Help(); err != nil { - t.Fatal(err) - } - if strings.Contains(help.String(), "one update") || len(cmd.Aliases) != 0 || cmd.Short != i18n.T("upgrade.short") || cmd.Long != i18n.T("upgrade.long") { - t.Fatalf("%s help did not refresh: %s", locale, help.String()) - } - for _, status := range []string{"current", "updated", "pending"} { - var text bytes.Buffer - result := upgradeResult{&updatecheck.Result{CurrentVersion: "1.0.0", LatestVersion: "v1.2.3", Status: status}} - result.RenderTTY(&text) - containsChinese := strings.ContainsFunc(help.String()+text.String(), func(r rune) bool { return unicode.Is(unicode.Han, r) }) - if text.Len() == 0 || containsChinese != (locale == "zh-CN") || !strings.Contains(text.String(), "v1.2.3") { - t.Fatalf("%s %s output: %q", locale, status, text.String()) - } - } - } -} diff --git a/packages/cli/internal/transport/http/handlers_catalog_test.go b/packages/cli/internal/transport/http/handlers_catalog_test.go deleted file mode 100644 index 46cc605a..00000000 --- a/packages/cli/internal/transport/http/handlers_catalog_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package serve - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - catalog "github.com/torchstellar-team/one-cli/packages/cli/internal/core/backend" -) - -func catalogRequest(t *testing.T, backendCatalog *catalog.Catalog) (*httptest.ResponseRecorder, []byte) { - t.Helper() - mux := BuildMux(MuxOpts{ - ExpectedHosts: map[string]struct{}{"127.0.0.1": {}}, - SelfOrigin: "http://127.0.0.1", - UIDisabled: true, - Catalog: backendCatalog, - }) - req := httptest.NewRequest(http.MethodGet, "/api/catalog", nil) - req.Host = "127.0.0.1" - rec := httptest.NewRecorder() - mux.ServeHTTP(rec, req) - return rec, rec.Body.Bytes() -} - -func TestCatalogEndpointUsesInjectedCatalog(t *testing.T) { - t.Parallel() - - custom, err := catalog.New(catalog.BackendSpec{ - ID: catalog.BackendID{Domain: catalog.DomainEnv, Name: "test"}, - Pair: "env/test", - Capabilities: []catalog.Capability{catalog.CapabilityEnvGet}, - }) - if err != nil { - t.Fatal(err) - } - rec, raw := catalogRequest(t, custom) - if rec.Code != http.StatusOK { - t.Fatalf("GET /api/catalog status = %d, body = %s", rec.Code, raw) - } - var payload catalogResponse - if err := json.Unmarshal(raw, &payload); err != nil { - t.Fatal(err) - } - if payload.Schema != schemaCatalog { - t.Fatalf("schema = %q, want %q", payload.Schema, schemaCatalog) - } - if len(payload.Backends) != 1 || payload.Backends[0].Pair != "env/test" { - t.Fatalf("backends = %#v", payload.Backends) - } -} - -func TestCatalogEndpointContainsNoCredentialValues(t *testing.T) { - t.Parallel() - - rec, raw := catalogRequest(t, nil) - if rec.Code != http.StatusOK { - t.Fatalf("GET /api/catalog status = %d, body = %s", rec.Code, raw) - } - var payload catalogResponse - if err := json.Unmarshal(raw, &payload); err != nil { - t.Fatal(err) - } - if len(payload.Backends) != 1 { - t.Fatalf("len(backends) = %d, want 1", len(payload.Backends)) - } - if strings.Contains(string(raw), "profile") || strings.Contains(string(raw), "credentials") { - t.Fatal("catalog still exposes credential profile schema") - } -} diff --git a/packages/cli/internal/transport/http/handlers_preferences_test.go b/packages/cli/internal/transport/http/handlers_preferences_test.go deleted file mode 100644 index 25f2aa31..00000000 --- a/packages/cli/internal/transport/http/handlers_preferences_test.go +++ /dev/null @@ -1,120 +0,0 @@ -package serve - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/preferences" -) - -// newPrefsMux constructs an HTTP handler wired the same way `one -// serve` does at startup. Tests issue requests through this without -// binding a port. Reuses withIsolatedConfig from handlers_configure_test.go. -func newPrefsMux(t *testing.T) http.Handler { - t.Helper() - return BuildMux(MuxOpts{ - ExpectedHosts: map[string]struct{}{"127.0.0.1": {}, "localhost": {}}, - SelfOrigin: "http://127.0.0.1", - }) -} - -func TestPreferencesGET_DefaultsToAuto(t *testing.T) { - withIsolatedConfig(t) - mux := newPrefsMux(t) - - req := httptest.NewRequest(http.MethodGet, "/api/preferences", nil) - req.Host = "127.0.0.1" - rec := httptest.NewRecorder() - mux.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status: want 200, got %d body=%s", rec.Code, rec.Body.String()) - } - var got map[string]any - if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { - t.Fatalf("decode: %v body=%s", err, rec.Body.String()) - } - if got["schema"] != schemaPreferences { - t.Errorf("schema: want %q, got %v", schemaPreferences, got["schema"]) - } - if got["stored_locale"] != preferences.LocaleAuto { - t.Errorf("stored_locale: want %q, got %v", preferences.LocaleAuto, got["stored_locale"]) - } -} - -func TestPreferencesPUT_PersistsToDisk(t *testing.T) { - withIsolatedConfig(t) - mux := newPrefsMux(t) - - body := bytes.NewBufferString(`{"locale":"zh-CN"}`) - req := httptest.NewRequest(http.MethodPut, "/api/preferences", body) - req.Host = "127.0.0.1" - req.Header.Set("Origin", "http://127.0.0.1") - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - mux.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status: want 200, got %d body=%s", rec.Code, rec.Body.String()) - } - var got preferencesPayload - if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { - t.Fatalf("decode: %v body=%s", err, rec.Body.String()) - } - if got.StoredLocale != "zh-CN" { - t.Errorf("stored_locale: want zh-CN, got %q", got.StoredLocale) - } - - // Reading back from disk confirms persistence. preferences.Load - // honours XDG_CONFIG_HOME (set by withIsolatedConfig) so the - // tempdir is implicit. - prefs, err := preferences.Load() - if err != nil { - t.Fatalf("Load: %v", err) - } - if prefs.Locale != "zh-CN" { - t.Errorf("disk locale: want zh-CN, got %q", prefs.Locale) - } -} - -func TestPreferencesPUT_RejectsBadLocale(t *testing.T) { - withIsolatedConfig(t) - mux := newPrefsMux(t) - - for _, bad := range []string{`{"locale":"klingon"}`, `{"locale":""}`, `{"locale":"zh_CN"}`} { - t.Run(bad, func(t *testing.T) { - req := httptest.NewRequest(http.MethodPut, "/api/preferences", - strings.NewReader(bad)) - req.Host = "127.0.0.1" - req.Header.Set("Origin", "http://127.0.0.1") - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - mux.ServeHTTP(rec, req) - - if rec.Code != http.StatusBadRequest { - t.Errorf("status: want 400, got %d body=%s", rec.Code, rec.Body.String()) - } - }) - } -} - -func TestPreferencesPUT_RejectsUnknownField(t *testing.T) { - withIsolatedConfig(t) - mux := newPrefsMux(t) - - req := httptest.NewRequest(http.MethodPut, "/api/preferences", - strings.NewReader(`{"locale":"zh-CN","colour":"red"}`)) - req.Host = "127.0.0.1" - req.Header.Set("Origin", "http://127.0.0.1") - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - mux.ServeHTTP(rec, req) - - if rec.Code != http.StatusBadRequest { - t.Errorf("status: want 400 on unknown field, got %d", rec.Code) - } -} diff --git a/packages/cli/internal/transport/http/handlers_project_create_test.go b/packages/cli/internal/transport/http/handlers_project_create_test.go deleted file mode 100644 index 1674018e..00000000 --- a/packages/cli/internal/transport/http/handlers_project_create_test.go +++ /dev/null @@ -1,261 +0,0 @@ -package serve - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "sync" - "testing" - - workspacecore "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" -) - -func TestCreateProjectInSelectedWorkspace(t *testing.T) { - registry := newRegistryService(t) - launchRoot := seedRegistryWorkspace(t, "launch", "Launch", "") - selectedRoot := seedRegistryWorkspace(t, "selected", "Selected", "") - selected := observeRegistryWorkspace(t, registry, selectedRoot) - handler := newRegistryMux(t, launchRoot, registry) - before := snapshotRepositoryTree(t, launchRoot) - path := "/api/workspaces/" + selected.EntryID + "/projects" - response := registryRequest(t, handler, http.MethodPost, path, strings.NewReader(`{"name":"web","templateId":"react-spa"}`)) - if response.Code != http.StatusCreated { - t.Fatalf("status = %d: %s", response.Code, response.Body.String()) - } - var created createProjectResponse - if err := json.Unmarshal(response.Body.Bytes(), &created); err != nil { - t.Fatal(err) - } - if created.Name != "web" || created.RelativeDir != "apps/web" || created.TemplateID != "react-spa" { - t.Fatalf("created = %#v", created) - } - if _, err := os.Stat(filepath.Join(selectedRoot, "apps/web/package.json")); err != nil { - t.Fatal(err) - } - manifest, err := workspacecore.ReadManifest(selectedRoot) - if err != nil { - t.Fatal(err) - } - if len(manifest.Projects) != 1 || manifest.Projects[0].Name != "web" { - t.Fatalf("manifest = %#v", manifest) - } - assertRepositoryUnchanged(t, launchRoot, before) - before = snapshotRepositoryTree(t, selectedRoot) - for _, templateID := range []string{"react-spa", "go-api"} { - response = registryRequest(t, handler, http.MethodPost, path, strings.NewReader(`{"name":"web","templateId":"`+templateID+`"}`)) - if response.Code != http.StatusConflict { - t.Fatalf("duplicate status = %d: %s", response.Code, response.Body.String()) - } - assertRepositoryUnchanged(t, selectedRoot, before) - } -} - -func TestCreateProjectRejectsInvalidRequestsWithoutWrites(t *testing.T) { - registry := newRegistryService(t) - root := seedRegistryWorkspace(t, "selected", "Selected", "") - selected := observeRegistryWorkspace(t, registry, root) - handler := newRegistryMux(t, root, registry) - before := snapshotRepositoryTree(t, root) - for _, test := range []struct { - body string - status int - }{ - {`{"name":"../escape","templateId":"react-spa"}`, 400}, - {`{"name":"web/escape","templateId":"react-spa"}`, 400}, - {`{"name":"","templateId":"react-spa"}`, 400}, - {`{"name":"web","templateId":""}`, 400}, - {`{"name":"web","templateId":"unknown"}`, 404}, - {`{"name":"web","templateId":"react-spa","root":"/tmp"}`, 400}, - {`{"name":"web","templateId":"react-spa"} {}`, 400}, - } { - response := registryRequest(t, handler, http.MethodPost, "/api/workspaces/"+selected.EntryID+"/projects", strings.NewReader(test.body)) - if response.Code != test.status { - t.Errorf("%s: status = %d: %s", test.body, response.Code, response.Body.String()) - } - assertRepositoryUnchanged(t, root, before) - } - request := httptest.NewRequest(http.MethodPost, "/api/workspaces/"+selected.EntryID+"/projects", strings.NewReader(`{"name":"web","templateId":"react-spa"}`)) - request.Host = registryTestHost - request.Header.Set("Origin", "http://external.example") - response := httptest.NewRecorder() - handler.ServeHTTP(response, request) - if response.Code != http.StatusForbidden { - t.Fatalf("origin status = %d", response.Code) - } - assertRepositoryUnchanged(t, root, before) -} - -func TestCreateProjectRejectsUnknownAndConflictingWorkspace(t *testing.T) { - registry := newRegistryService(t) - root := seedRegistryWorkspace(t, "copied-id", "A", "") - other := seedRegistryWorkspace(t, "copied-id", "B", "") - selected := observeRegistryWorkspace(t, registry, root) - observeRegistryWorkspace(t, registry, other) - handler := newRegistryMux(t, root, registry) - before := snapshotRepositoryTree(t, root) - for entry, status := range map[string]int{selected.EntryID: 409, "unknown": 404} { - response := registryRequest(t, handler, http.MethodPost, "/api/workspaces/"+entry+"/projects", strings.NewReader(`{"name":"web","templateId":"react-spa"}`)) - if response.Code != status { - t.Fatalf("status = %d: %s", response.Code, response.Body.String()) - } - } - assertRepositoryUnchanged(t, root, before) -} - -func TestCreateProjectConcurrentDuplicate(t *testing.T) { - registry := newRegistryService(t) - root := seedRegistryWorkspace(t, "selected", "Selected", "") - selected := observeRegistryWorkspace(t, registry, root) - handler := newRegistryMux(t, root, registry) - statuses := make(chan int, 2) - var wg sync.WaitGroup - for range 2 { - wg.Add(1) - go func() { - defer wg.Done() - response := registryRequest(t, handler, http.MethodPost, "/api/workspaces/"+selected.EntryID+"/projects", strings.NewReader(`{"name":"web","templateId":"react-spa"}`)) - statuses <- response.Code - }() - } - wg.Wait() - first, second := <-statuses, <-statuses - if !((first == 201 && second == 409) || (first == 409 && second == 201)) { - t.Fatalf("statuses = %d, %d", first, second) - } - manifest, err := workspacecore.ReadManifest(root) - if err != nil { - t.Fatal(err) - } - if len(manifest.Projects) != 1 { - t.Fatalf("projects = %#v", manifest.Projects) - } -} - -func TestProjectTemplatesLocalized(t *testing.T) { - original := i18n.Active() - t.Cleanup(func() { _ = i18n.Init(original) }) - for _, locale := range []string{"zh-CN", "en-US"} { - _ = i18n.Init(locale) - handler := newRegistryMux(t, "", nil) - response := registryRequest(t, handler, http.MethodGet, "/api/project-templates", nil) - if response.Code != http.StatusOK { - t.Fatal(response.Body.String()) - } - var result struct { - Templates []projectTemplate `json:"templates"` - } - if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil { - t.Fatal(err) - } - if len(result.Templates) == 0 { - t.Fatal("no templates") - } - for _, entry := range result.Templates { - if entry.ID == "electron-app" && len(entry.Projects) != 3 { - t.Fatalf("missing composite locations: %#v", entry) - } - if entry.Directory == "" || entry.Name != i18n.T("template."+entry.ID+".name") { - t.Fatalf("template = %#v", entry) - } - } - } -} - -func TestCreateEmptyProjectsInSelectedWorkspace(t *testing.T) { - registry := newRegistryService(t) - root := seedRegistryWorkspace(t, "selected", "Selected", "") - selected := observeRegistryWorkspace(t, registry, root) - handler := newRegistryMux(t, root, registry) - for _, tc := range []struct{ id, name, dir string }{ - {"empty-app", "web", "apps/web"}, - {"empty-service", "api", "services/api"}, - {"empty-library", "shared", "packages/shared"}, - } { - payload, err := json.Marshal(createProjectRequest{Name: tc.name, TemplateID: tc.id}) - if err != nil { - t.Fatal(err) - } - response := registryRequest(t, handler, http.MethodPost, "/api/workspaces/"+selected.EntryID+"/projects", strings.NewReader(string(payload))) - if response.Code != http.StatusCreated { - t.Fatalf("%s: %d %s", tc.id, response.Code, response.Body.String()) - } - var created createProjectResponse - if err := json.Unmarshal(response.Body.Bytes(), &created); err != nil { - t.Fatal(err) - } - if created.Name != tc.name || created.RelativeDir != tc.dir || created.TemplateID != tc.id { - t.Fatalf("created = %#v", created) - } - entries, err := os.ReadDir(filepath.Join(root, tc.dir)) - if err != nil || len(entries) != 1 || entries[0].Name() != ".gitkeep" { - t.Fatalf("empty project files: %v, %v", entries, err) - } - } - manifest, err := workspacecore.ReadManifest(root) - if err != nil { - t.Fatal(err) - } - if len(manifest.Projects) != 3 { - t.Fatalf("manifest = %#v", manifest) - } - for _, project := range manifest.Projects { - if project.Toolchain != "none" { - t.Fatalf("empty project = %#v", project) - } - } -} - -func TestCreateElectronGroupAndRejectMemberConflicts(t *testing.T) { - registry := newRegistryService(t) - root := seedRegistryWorkspace(t, "desktop", "Desktop", "") - selected := observeRegistryWorkspace(t, registry, root) - handler := newRegistryMux(t, root, registry) - endpoint := "/api/workspaces/" + selected.EntryID + "/projects" - response := registryRequest(t, handler, http.MethodPost, endpoint, strings.NewReader(`{"name":"desktop","templateId":"electron-app"}`)) - if response.Code != http.StatusCreated { - t.Fatalf("create: %d %s", response.Code, response.Body.String()) - } - var created createProjectResponse - if err := json.Unmarshal(response.Body.Bytes(), &created); err != nil { - t.Fatal(err) - } - if created.Name != "desktop" || len(created.Projects) != 3 { - t.Fatalf("created = %#v", created) - } - for i, relative := range []string{"apps/desktop-renderer", "services/desktop-main", "packages/desktop-preload"} { - if created.Projects[i].RelativeDir != relative { - t.Fatalf("member = %#v", created.Projects[i]) - } - if _, err := os.Stat(filepath.Join(root, relative, "package.json")); err != nil { - t.Fatal(err) - } - } - before := snapshotRepositoryTree(t, root) - for _, body := range []string{ - `{"name":"desktop","templateId":"electron-app"}`, - `{"name":"desktop","templateId":"react-spa"}`, - `{"name":"desktop-main","templateId":"react-spa"}`, - } { - response = registryRequest(t, handler, http.MethodPost, endpoint, strings.NewReader(body)) - if response.Code != http.StatusConflict { - t.Fatalf("duplicate: %d %s", response.Code, response.Body.String()) - } - assertRepositoryUnchanged(t, root, before) - } - if err := os.MkdirAll(filepath.Join(root, "services/studio-main"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "services/studio-main/keep.txt"), []byte("keep"), 0o644); err != nil { - t.Fatal(err) - } - before = snapshotRepositoryTree(t, root) - response = registryRequest(t, handler, http.MethodPost, endpoint, strings.NewReader(`{"name":"studio","templateId":"electron-app"}`)) - if response.Code != http.StatusConflict { - t.Fatalf("member conflict: %d %s", response.Code, response.Body.String()) - } - assertRepositoryUnchanged(t, root, before) -} diff --git a/packages/cli/internal/transport/http/handlers_secrets_test.go b/packages/cli/internal/transport/http/handlers_secrets_test.go deleted file mode 100644 index 6d343542..00000000 --- a/packages/cli/internal/transport/http/handlers_secrets_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package serve - -import ( - "net/http" - "strings" - "testing" - - workspacecore "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" -) - -func TestSecretRoutesRejectUnconfiguredEnvironment(t *testing.T) { - root := seedWorkspace(t) - manifest, err := workspacecore.ReadManifest(root) - if err != nil { - t.Fatal(err) - } - manifest.Env = nil - if err := workspacecore.WriteManifest(root, manifest); err != nil { - t.Fatal(err) - } - handler := newWorkspaceMux(t, root) - before := snapshotRepositoryTree(t, root) - - for _, tc := range []struct { - name string - method string - path string - body string - }{ - {name: "list", method: http.MethodGet, path: "/api/workspace/secrets"}, - {name: "delete", method: http.MethodDelete, path: "/api/workspace/secrets/HELLO"}, - {name: "get", method: http.MethodGet, path: "/api/workspace/secrets/HELLO"}, - {name: "create", method: http.MethodPost, path: "/api/workspace/secrets", body: `{"key":"HELLO","value":"world"}`}, - {name: "update", method: http.MethodPut, path: "/api/workspace/secrets/HELLO", body: `{"value":"world"}`}, - } { - t.Run(tc.name, func(t *testing.T) { - recorder := workspaceRequest(t, handler, tc.method, tc.path, strings.NewReader(tc.body)) - if recorder.Code != http.StatusConflict { - t.Fatalf("%s status = %d; body = %s", tc.name, recorder.Code, recorder.Body.String()) - } - if !strings.Contains(recorder.Body.String(), `"code":"INFISICAL_NOT_CONFIGURED"`) { - t.Fatalf("%s body = %s", tc.name, recorder.Body.String()) - } - }) - } - - assertRepositoryUnchanged(t, root, before) -} diff --git a/packages/cli/internal/transport/http/handlers_services_test.go b/packages/cli/internal/transport/http/handlers_services_test.go deleted file mode 100644 index d6d24284..00000000 --- a/packages/cli/internal/transport/http/handlers_services_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package serve - -import ( - "bufio" - "context" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/devservice" -) - -func TestServiceRoutesResolveScopeAndRejectInvalidLaunches(t *testing.T) { - withIsolatedConfig(t) - root := seedRegistryWorkspace(t, "dev-id", "Development", "web") - registry := newRegistryService(t) - entry := observeRegistryWorkspace(t, registry, root) - manager := devservice.New() - manager.Close() // validate payloads without starting a process - mux := BuildMux(MuxOpts{UIDisabled: true, ExpectedHosts: map[string]struct{}{registryTestHost: {}}, SelfOrigin: "http://" + registryTestHost, WorkspaceRoot: root, RegistryService: registry, ServiceManager: manager}) - for _, base := range []string{"/api/workspace", "/api/workspaces/" + entry.EntryID} { - for _, tc := range []struct { - path, body string - code int - }{ - {"/projects/web/service/start", `{"environment":"dev","command":"touch surprise"}`, 400}, - {"/projects/missing/service/start", `{"environment":"dev"}`, 404}, - {"/projects/web/service/start", `{"environment":"../unsafe"}`, 400}, - {"/projects/web/service/start", `{"environment":"dev"}`, 409}, - {"/projects/web/service/restart", `{"environment":"dev"}`, 409}, - {"/projects/web/service/stop", `{}`, 202}, - } { - rec := registryRequest(t, mux, http.MethodPost, base+tc.path, strings.NewReader(tc.body)) - if rec.Code != tc.code { - t.Fatalf("%s: %d %s", tc.path, rec.Code, rec.Body.String()) - } - } - rec := registryRequest(t, mux, http.MethodGet, base+"/services", nil) - if rec.Code != 200 || rec.Header().Get("Cache-Control") != "no-store" { - t.Fatal(rec) - } - } - req := httptest.NewRequest("POST", "/api/workspace/projects/web/service/start", strings.NewReader(`{}`)) - req.Host = registryTestHost - req.Header.Set("Origin", "https://evil.example") - rec := httptest.NewRecorder() - mux.ServeHTTP(rec, req) - if rec.Code != 403 { - t.Fatal(rec.Code) - } - rec = registryRequest(t, mux, http.MethodPost, "/api/workspaces/unknown/projects/web/service/start", strings.NewReader(`{}`)) - if rec.Code != 404 { - t.Fatal(rec.Code) - } -} -func TestServiceEventsFlushImmediatelyAndCancel(t *testing.T) { - manager := devservice.New() - defer manager.Close() - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { streamService(w, r, manager, "/test", "web") })) - defer server.Close() - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - req, _ := http.NewRequestWithContext(ctx, "GET", server.URL, nil) - res, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatal(err) - } - defer res.Body.Close() - line, err := bufio.NewReader(res.Body).ReadString('\n') - if err != nil || !strings.Contains(line, `"status":"stopped"`) { - t.Fatal(line, err) - } - cancel() -} diff --git a/packages/cli/internal/transport/http/handlers_session_test.go b/packages/cli/internal/transport/http/handlers_session_test.go deleted file mode 100644 index 3a076451..00000000 --- a/packages/cli/internal/transport/http/handlers_session_test.go +++ /dev/null @@ -1,121 +0,0 @@ -package serve - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - session "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/infisicalsession" - "github.com/zalando/go-keyring" -) - -func TestSessionStatusAndLogoutNeverExposeToken(t *testing.T) { - srv, _ := newTestServer(t) - raw, _ := json.Marshal(session.Session{Info: session.Info{UserID: "user", Email: "test@example.com", SiteURL: session.DefaultSiteURL, ExpiresAt: time.Now().Add(time.Hour)}, Token: "private-session-token"}) - if err := keyring.Set("one-cli.infisical", "session", string(raw)); err != nil { - t.Fatal(err) - } - res, body := apiRequest(t, srv, "GET", "/api/session", nil) - var status struct { - Session session.Info `json:"session"` - } - if err := json.Unmarshal(body, &status); err != nil { - t.Fatal(err) - } - if res.StatusCode != 200 || !status.Session.LoggedIn { - t.Fatalf("status: %d %s", res.StatusCode, body) - } - if strings.Contains(string(body), "private-session-token") || strings.Contains(string(body), `"token"`) { - t.Fatal("public status leaked token") - } - if res.Header.Get("Cache-Control") != "no-store" { - t.Fatal("session metadata was cacheable") - } - req, _ := http.NewRequest("DELETE", srv.URL+"/api/session", nil) - req.Header.Set("Origin", "https://untrusted.example") - rejected, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatal(err) - } - rejected.Body.Close() - if rejected.StatusCode != 403 { - t.Fatalf("cross-origin logout: %d", rejected.StatusCode) - } - if _, err = session.Require(); err != nil { - t.Fatal("rejected logout changed session") - } - res, _ = apiRequest(t, srv, "DELETE", "/api/session", strings.NewReader("")) - if res.StatusCode != 200 { - t.Fatalf("logout status: %d", res.StatusCode) - } - if _, err = session.Require(); err == nil { - t.Fatal("logout retained session") - } -} -func TestRemovedConfigureRoutesUnavailable(t *testing.T) { - srv, _ := newTestServer(t) - res, _ := apiRequest(t, srv, "GET", "/api/configure", nil) - if res.StatusCode != 404 { - t.Fatalf("removed route status: %d", res.StatusCode) - } -} - -func TestSharedProjectCreationAndDefaultLocationRoutes(t *testing.T) { - srv, _ := newTestServer(t) - creates := 0 - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch r.URL.Path { - case "/api/v1/workspace": - w.Write([]byte(`{"workspaces":[{"id":"shared","name":"shared-credentials","type":"secret-manager","orgId":"org"}]}`)) - case "/api/v2/workspace": - creates++ - var body map[string]string - json.NewDecoder(r.Body).Decode(&body) - if body["projectName"] != "Team" || body["type"] != "secret-manager" { - t.Errorf("unexpected creation: %v", body) - } - w.Write([]byte(`{"project":{"id":"shared"}}`)) - case "/api/v1/workspace/shared": - w.Write([]byte(`{"workspace":{"id":"shared","name":"shared-credentials","type":"secret-manager","orgId":"org","environments":[{"slug":"dev"}]}}`)) - default: - t.Errorf("unexpected request: %s", r.URL) - http.NotFound(w, r) - } - })) - defer upstream.Close() - raw, _ := json.Marshal(session.Session{Info: session.Info{UserID: "user", OrganizationID: "org", SiteURL: upstream.URL, ExpiresAt: time.Now().Add(time.Hour)}, Token: "private-session-token"}) - if err := keyring.Set("one-cli.infisical", "session", string(raw)); err != nil { - t.Fatal(err) - } - res, body := apiRequest(t, srv, "GET", "/api/global-env/location", nil) - if res.StatusCode != 200 || !strings.Contains(string(body), `"location": null`) || creates != 0 { - t.Fatalf("GET changed state: %s", body) - } - for _, path := range []string{"/api/infisical/projects", "/api/global-env/location/default"} { - req, _ := http.NewRequest("POST", srv.URL+path, strings.NewReader(`{"name":"Team"}`)) - req.Header.Set("Origin", "https://untrusted.example") - rejected, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatal(err) - } - rejected.Body.Close() - if rejected.StatusCode != 403 { - t.Fatalf("cross-origin mutation: %d", rejected.StatusCode) - } - } - res, body = apiRequest(t, srv, "POST", "/api/infisical/projects", strings.NewReader(`{"name":"Team"}`)) - if res.StatusCode != 201 || creates != 1 || strings.Contains(string(body), "private-session-token") { - t.Fatalf("create: %d %s", res.StatusCode, body) - } - res, body = apiRequest(t, srv, "POST", "/api/global-env/location/default", strings.NewReader(`{}`)) - if res.StatusCode != 200 || creates != 1 || !strings.Contains(string(body), `"defaultEnvironment": "dev"`) { - t.Fatalf("default: %d %s", res.StatusCode, body) - } - if res.Header.Get("Cache-Control") != "no-store" { - t.Fatal("location metadata was cacheable") - } -} diff --git a/packages/cli/internal/transport/http/handlers_workspace_bind_test.go b/packages/cli/internal/transport/http/handlers_workspace_bind_test.go deleted file mode 100644 index 3786690f..00000000 --- a/packages/cli/internal/transport/http/handlers_workspace_bind_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package serve - -import ( - "fmt" - "net/http" - "net/http/httptest" - "strings" - "testing" - - workspacecore "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" -) - -func TestWorkspaceBindingUsesSelectedWorkspaceAndRejectsStaleRequests(t *testing.T) { - registry := newRegistryService(t) - launch := seedRegistryWorkspace(t, "launch", "Launch", "") - root := seedRegistryWorkspace(t, "selected", "Selected", "") - manifest, err := workspacecore.ReadManifest(root) - if err != nil { - t.Fatal(err) - } - manifest.Env = &workspacecore.EnvironmentConfig{ProjectID: "selected-project", Environments: []string{"dev"}} - if err := workspacecore.WriteManifest(root, manifest); err != nil { - t.Fatal(err) - } - selected := observeRegistryWorkspace(t, registry, root) - handler := newRegistryMux(t, launch, registry) - before := snapshotRepositoryTree(t, launch) - selectedBefore := snapshotRepositoryTree(t, root) - _, revision, err := workspacecore.ReadManifestSnapshot(root) - if err != nil { - t.Fatal(err) - } - path := "/api/workspaces/" + selected.EntryID + "/environment/bind" - for _, test := range []struct { - body string - status int - }{ - {`{"create":true}`, http.StatusBadRequest}, - {`{"revision":"stale","create":true}`, http.StatusConflict}, - {fmt.Sprintf(`{"revision":%q,"create":true,"projectId":"other"}`, revision), http.StatusBadRequest}, - {fmt.Sprintf(`{"revision":%q,"create":false}`, revision), http.StatusBadRequest}, - {fmt.Sprintf(`{"revision":%q,"create":true}`, revision), http.StatusOK}, - } { - response := registryRequest(t, handler, http.MethodPost, path, strings.NewReader(test.body)) - if response.Code != test.status { - t.Fatalf("%s: status=%d body=%s", test.body, response.Code, response.Body.String()) - } - if test.status == http.StatusOK && !strings.Contains(response.Body.String(), `"project_id": "selected-project"`) { - t.Fatalf("bound wrong workspace: %s", response.Body.String()) - } - assertRepositoryUnchanged(t, launch, before) - assertRepositoryUnchanged(t, root, selectedBefore) - } - request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"create":true}`)) - request.Host = registryTestHost - request.Header.Set("Origin", "http://external.example") - response := httptest.NewRecorder() - handler.ServeHTTP(response, request) - if response.Code != http.StatusForbidden { - t.Fatalf("cross-origin binding status=%d", response.Code) - } -} diff --git a/packages/cli/internal/transport/http/handlers_workspace_mutate_test.go b/packages/cli/internal/transport/http/handlers_workspace_mutate_test.go deleted file mode 100644 index 7689f3fd..00000000 --- a/packages/cli/internal/transport/http/handlers_workspace_mutate_test.go +++ /dev/null @@ -1,218 +0,0 @@ -package serve - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "io/fs" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - - workspacecore "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" -) - -const workspaceTestHost = "dashboard.test" - -func seedWorkspace(t *testing.T) string { - t.Helper() - root := t.TempDir() - manifest := &workspacecore.Manifest{ - Version: workspacecore.ManifestVersion, - Workspace: &workspacecore.ManifestWorkspace{ID: "demo", Name: "demo"}, - - Env: &workspacecore.EnvironmentConfig{ProjectID: "remote", Environments: []string{"dev", "staging", "prod"}}, - - Projects: []workspacecore.ManifestProject{{ - Name: "web", RelativeDir: "apps/web", Toolchain: "node", - }}, - } - if err := workspacecore.WriteManifest(root, manifest); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "README.md"), []byte("repository content\n"), 0o644); err != nil { - t.Fatal(err) - } - return root -} - -func newWorkspaceMux(t *testing.T, root string) http.Handler { - t.Helper() - withIsolatedConfig(t) - return BuildMux(MuxOpts{ - UIDisabled: true, - ExpectedHosts: map[string]struct{}{workspaceTestHost: {}}, - SelfOrigin: "http://" + workspaceTestHost, - WorkspaceRoot: root, - }) -} - -func workspaceRequest( - t *testing.T, - handler http.Handler, - method, path string, - body io.Reader, -) *httptest.ResponseRecorder { - t.Helper() - request := httptest.NewRequest(method, path, body) - request.Host = workspaceTestHost - if method != http.MethodGet && method != http.MethodHead && method != http.MethodOptions { - request.Header.Set("Origin", "http://"+workspaceTestHost) - request.Header.Set("Content-Type", "application/json") - } - recorder := httptest.NewRecorder() - handler.ServeHTTP(recorder, request) - return recorder -} - -func snapshotRepositoryTree(t *testing.T, root string) map[string][]byte { - t.Helper() - result := map[string][]byte{} - err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - if entry.IsDir() { - return nil - } - relative, err := filepath.Rel(root, path) - if err != nil { - return err - } - value, err := os.ReadFile(path) - if err != nil { - return err - } - result[filepath.ToSlash(relative)] = value - return nil - }) - if err != nil { - t.Fatal(err) - } - return result -} - -func assertRepositoryUnchanged(t *testing.T, root string, before map[string][]byte) { - t.Helper() - after := snapshotRepositoryTree(t, root) - if len(after) != len(before) { - t.Fatalf("repository file count changed: got %d want %d", len(after), len(before)) - } - for path, expected := range before { - if !bytes.Equal(after[path], expected) { - t.Fatalf("repository file %q changed", path) - } - } -} - -type workspaceProfileSettingsWire struct { - Schema string `json:"schema"` - Root string `json:"root"` - Environment string `json:"environment"` - Revision string `json:"revision"` - Domain string `json:"domain"` - Backend string `json:"backend"` - Configurable bool `json:"configurable"` - SelectedProfile string `json:"selectedProfile"` - Profile *struct { - Name string `json:"name"` - Source string `json:"source"` - } `json:"profile"` -} - -type workspaceSettingsWire struct { - Schema string `json:"schema"` - Root string `json:"root"` - Environment string `json:"environment"` - Project struct { - Name string `json:"name"` - Environment struct { - Backend string `json:"backend"` - SelectedProfile string `json:"selectedProfile"` - Profile *struct { - Name string `json:"name"` - Source string `json:"source"` - } `json:"profile"` - } `json:"environment"` - } `json:"project"` -} - -func TestLegacyRepositoryMutationRoutesAlwaysReturnStableReadOnlyConflict(t *testing.T) { - paths := []string{ - "/api/workspace/projects/web", - "/api/workspace/projects/web/environment", - } - for _, path := range paths { - for _, body := range []string{`{}`, `{this is not json`} { - root := seedWorkspace(t) - handler := newWorkspaceMux(t, root) - before := snapshotRepositoryTree(t, root) - recorder := workspaceRequest(t, handler, http.MethodPut, path, strings.NewReader(body)) - if recorder.Code != http.StatusConflict { - t.Fatalf("%s status = %d; body = %s", path, recorder.Code, recorder.Body.String()) - } - var envelope struct { - Error struct { - Code string `json:"code"` - } `json:"error"` - } - if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { - t.Fatal(err) - } - if envelope.Error.Code != "SERVE_REPOSITORY_READ_ONLY" { - t.Fatalf("%s code = %q", path, envelope.Error.Code) - } - assertRepositoryUnchanged(t, root, before) - } - } -} - -func TestManifestDraftRouteRequiresCurrentRevisionAndWritesAllowlistedFields(t *testing.T) { - root := seedWorkspace(t) - handler := newWorkspaceMux(t, root) - - read := workspaceRequest(t, handler, http.MethodGet, "/api/workspace/projects/web?env=dev", nil) - if read.Code != http.StatusOK { - t.Fatalf("GET status = %d; body = %s", read.Code, read.Body.String()) - } - var settings struct { - Revision string `json:"revision"` - } - if err := json.Unmarshal(read.Body.Bytes(), &settings); err != nil { - t.Fatal(err) - } - if settings.Revision == "" { - t.Fatal("project settings omitted manifest revision") - } - - removed := workspaceRequest(t, handler, http.MethodPut, "/api/workspace/environment/backend?env=dev", strings.NewReader(`{"backend":"dotenv"}`)) - if removed.Code == http.StatusOK { - t.Fatal("removed backend switch is still available") - } - body := fmt.Sprintf(`{"revision":%q,"workspace":{"environment":{"backend":"infisical","projectId":"updated"}},"changes":[]}`, settings.Revision) - written := workspaceRequest(t, handler, http.MethodPut, "/api/workspace/manifest", strings.NewReader(body)) - if written.Code != http.StatusOK { - t.Fatalf("PUT status = %d; body = %s", written.Code, written.Body.String()) - } - manifest, err := workspacecore.ReadManifest(root) - if err != nil { - t.Fatal(err) - } - if manifest.Env.ProjectID != "updated" { - t.Fatalf("binding = %+v", manifest.Env) - } - - if manifest.Env == nil || - workspacecore.EnvBackend(manifest) != "infisical" { - t.Fatalf("workspace env backend = %#v", manifest.Env) - } - - stale := workspaceRequest(t, handler, http.MethodPut, "/api/workspace/manifest", strings.NewReader(body)) - if stale.Code != http.StatusConflict || !strings.Contains(stale.Body.String(), "SERVE_MANIFEST_CONFLICT") { - t.Fatalf("stale PUT status = %d; body = %s", stale.Code, stale.Body.String()) - } -} diff --git a/packages/cli/internal/transport/http/handlers_workspace_test.go b/packages/cli/internal/transport/http/handlers_workspace_test.go deleted file mode 100644 index 3267906e..00000000 --- a/packages/cli/internal/transport/http/handlers_workspace_test.go +++ /dev/null @@ -1,113 +0,0 @@ -package serve - -// Locks the HTTP wire contract for /workspace/overview. Same isolated-tmp -// pattern as handlers_configure_test.go; just swaps which directory the -// handler reads from instead of XDG_CONFIG_HOME. - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" -) - -// newOverviewServer builds a mux with WorkspaceRoot pinned to root. Mirrors -// newTestServer's two-step "patch hosts after addr is known" dance. -func newOverviewServer(t *testing.T, root string) (*httptest.Server, string) { - t.Helper() - withIsolatedConfig(t) - mux := BuildMux(MuxOpts{ - UIDisabled: true, - WorkspaceRoot: root, - }) - srv := httptest.NewServer(mux) - t.Cleanup(srv.Close) - addr := strings.TrimPrefix(srv.URL, "http://") - srv.Config.Handler = BuildMux(MuxOpts{ - UIDisabled: true, - ExpectedHosts: map[string]struct{}{addr: {}}, - SelfOrigin: srv.URL, - WorkspaceRoot: root, - }) - return srv, srv.URL -} - -func TestOverview_NoWorkspace_ReturnsPresentFalse(t *testing.T) { - srv, _ := newOverviewServer(t, "") - res, raw := apiRequest(t, srv, http.MethodGet, "/api/workspace/overview", nil) - if res.StatusCode != 200 { - t.Fatalf("status = %d; body = %s", res.StatusCode, raw) - } - var got workspace.Overview - if err := json.Unmarshal(raw, &got); err != nil { - t.Fatalf("decode: %v (%s)", err, raw) - } - if got.Schema != workspace.OverviewSchema { - t.Errorf("schema = %q", got.Schema) - } - if got.Present { - t.Errorf("present = true; want false") - } -} - -func TestOverview_PopulatedWorkspace(t *testing.T) { - tmp := t.TempDir() - m := &workspace.Manifest{ - Version: workspace.ManifestVersion, - Workspace: &workspace.ManifestWorkspace{ID: "demo", Name: "demo"}, - - Env: &workspace.EnvironmentConfig{ProjectID: "remote", Environments: []string{"dev"}}, - - Projects: []workspace.ManifestProject{ - { - Name: "web", RelativeDir: "apps/web", Toolchain: "node", - }, - }, - } - if err := workspace.WriteManifest(tmp, m); err != nil { - t.Fatalf("WriteManifest: %v", err) - } - - srv, _ := newOverviewServer(t, tmp) - res, raw := apiRequest(t, srv, http.MethodGet, "/api/workspace/overview", nil) - if res.StatusCode != 200 { - t.Fatalf("status = %d; body = %s", res.StatusCode, raw) - } - var got workspace.Overview - if err := json.Unmarshal(raw, &got); err != nil { - t.Fatalf("decode: %v (%s)", err, raw) - } - if !got.Present { - t.Fatalf("present = false; want true") - } - if len(got.Projects) != 1 || got.Projects[0].Name != "web" { - t.Errorf("projects = %+v", got.Projects) - } - if len(got.Projects[0].Issues) != 0 { - t.Errorf("project issues = %+v; want none", got.Projects[0].Issues) - } -} - -func TestOverview_BadManifest_500WithErrorEnvelope(t *testing.T) { - tmp := t.TempDir() - if err := os.WriteFile(filepath.Join(tmp, workspace.ManifestFilename), []byte("{not json"), 0o644); err != nil { - t.Fatalf("write: %v", err) - } - srv, _ := newOverviewServer(t, tmp) - res, raw := apiRequest(t, srv, http.MethodGet, "/api/workspace/overview", nil) - if res.StatusCode != 500 { - t.Fatalf("status = %d; body = %s", res.StatusCode, raw) - } - var env map[string]any - if err := json.Unmarshal(raw, &env); err != nil { - t.Fatalf("decode envelope: %v (%s)", err, raw) - } - if env["schema"] != "one-cli/error/v1" { - t.Errorf("schema = %v", env["schema"]) - } -} diff --git a/packages/cli/internal/transport/http/handlers_workspaces_test.go b/packages/cli/internal/transport/http/handlers_workspaces_test.go deleted file mode 100644 index 3a379c38..00000000 --- a/packages/cli/internal/transport/http/handlers_workspaces_test.go +++ /dev/null @@ -1,328 +0,0 @@ -package serve - -import ( - "bytes" - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - - registrylocal "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/workspaceregistry/local" - workspaceapp "github.com/torchstellar-team/one-cli/packages/cli/internal/application/workspace" - workspacecore "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" -) - -const registryTestHost = "one.test" - -func seedRegistryWorkspace(t *testing.T, id, name, projectName string) string { - t.Helper() - root := t.TempDir() - manifest := &workspacecore.Manifest{ - Version: workspacecore.ManifestVersion, - Workspace: &workspacecore.ManifestWorkspace{ID: id, Name: name}, - } - if projectName != "" { - manifest.Projects = []workspacecore.ManifestProject{{ - Name: projectName, RelativeDir: "apps/" + projectName, Toolchain: "node", - }} - } - if err := workspacecore.WriteManifest(root, manifest); err != nil { - t.Fatalf("WriteManifest: %v", err) - } - return root -} - -func newRegistryService(t *testing.T) *workspaceapp.RegistryService { - t.Helper() - store := registrylocal.NewAt(filepath.Join(t.TempDir(), "workspaces.json")) - service, err := workspaceapp.NewRegistryService(store) - if err != nil { - t.Fatalf("NewRegistryService: %v", err) - } - return service -} - -func observeRegistryWorkspace( - t *testing.T, - service *workspaceapp.RegistryService, - root string, -) workspaceapp.RegisteredWorkspace { - t.Helper() - registered, err := service.Observe(context.Background(), root, "serve") - if err != nil { - t.Fatalf("Observe(%q): %v", root, err) - } - return registered -} - -func newRegistryMux( - t *testing.T, - launchRoot string, - registry *workspaceapp.RegistryService, -) http.Handler { - t.Helper() - withIsolatedConfig(t) - return BuildMux(MuxOpts{ - UIDisabled: true, - ExpectedHosts: map[string]struct{}{registryTestHost: {}}, - SelfOrigin: "http://" + registryTestHost, - WorkspaceRoot: launchRoot, - RegistryService: registry, - }) -} - -func registryRequest( - t *testing.T, - handler http.Handler, - method, path string, - body io.Reader, -) *httptest.ResponseRecorder { - t.Helper() - req := httptest.NewRequest(method, path, body) - req.Host = registryTestHost - if method != http.MethodGet && method != http.MethodHead && method != http.MethodOptions { - req.Header.Set("Origin", "http://"+registryTestHost) - req.Header.Set("Content-Type", "application/json") - } - recorder := httptest.NewRecorder() - handler.ServeHTTP(recorder, req) - return recorder -} - -func TestWorkspacesListMarksLaunchWorkspaceCurrent(t *testing.T) { - registry := newRegistryService(t) - launchRoot := seedRegistryWorkspace(t, "launch-id", "Launch", "launch-web") - otherRoot := seedRegistryWorkspace(t, "other-id", "Other", "other-web") - launch := observeRegistryWorkspace(t, registry, launchRoot) - observeRegistryWorkspace(t, registry, otherRoot) - - recorder := registryRequest(t, newRegistryMux(t, launchRoot, registry), - http.MethodGet, "/api/workspaces", nil) - if recorder.Code != http.StatusOK { - t.Fatalf("status = %d; body = %s", recorder.Code, recorder.Body.String()) - } - var response workspaceapp.WorkspaceRegistryResponse - if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { - t.Fatalf("decode: %v; body = %s", err, recorder.Body.String()) - } - if response.Schema != workspaceapp.WorkspaceRegistrySchema { - t.Fatalf("schema = %q", response.Schema) - } - if response.CurrentEntryID != launch.EntryID { - t.Fatalf("currentEntryId = %q; want %q", response.CurrentEntryID, launch.EntryID) - } - if len(response.Workspaces) != 2 { - t.Fatalf("workspaces = %#v", response.Workspaces) - } -} - -func TestWorkspacesSelectedOverviewAndProject(t *testing.T) { - registry := newRegistryService(t) - launchRoot := seedRegistryWorkspace(t, "launch-id", "Launch", "launch-web") - selectedRoot := seedRegistryWorkspace(t, "selected-id", "Selected", "selected-web") - observeRegistryWorkspace(t, registry, launchRoot) - selected := observeRegistryWorkspace(t, registry, selectedRoot) - handler := newRegistryMux(t, launchRoot, registry) - - recorder := registryRequest(t, handler, http.MethodGet, - "/api/workspaces/"+selected.EntryID+"/overview", nil) - if recorder.Code != http.StatusOK { - t.Fatalf("overview status = %d; body = %s", recorder.Code, recorder.Body.String()) - } - var overview workspacecore.Overview - if err := json.Unmarshal(recorder.Body.Bytes(), &overview); err != nil { - t.Fatalf("decode overview: %v", err) - } - if overview.Workspace == nil || overview.Workspace.Name != "Selected" || - len(overview.Projects) != 1 || overview.Projects[0].Name != "selected-web" { - t.Fatalf("overview came from wrong root: %#v", overview) - } - - recorder = registryRequest(t, handler, http.MethodGet, - "/api/workspaces/"+selected.EntryID+"/projects/selected-web", nil) - if recorder.Code != http.StatusOK { - t.Fatalf("project status = %d; body = %s", recorder.Code, recorder.Body.String()) - } - var project workspaceSettingsWire - if err := json.Unmarshal(recorder.Body.Bytes(), &project); err != nil { - t.Fatalf("decode project: %v", err) - } - if project.Project.Name != "selected-web" { - t.Fatalf("project came from wrong root: %#v", project.Project) - } -} - -func TestWorkspacesLegacyProjectMutationIsReadOnlyWithoutResolvingBodyRoot(t *testing.T) { - registry := newRegistryService(t) - launchRoot := seedRegistryWorkspace(t, "launch-id", "Launch", "launch-web") - selectedRoot := seedRegistryWorkspace(t, "selected-id", "Selected", "selected-web") - observeRegistryWorkspace(t, registry, launchRoot) - selected := observeRegistryWorkspace(t, registry, selectedRoot) - handler := newRegistryMux(t, launchRoot, registry) - before, err := workspacecore.ReadManifest(selectedRoot) - if err != nil { - t.Fatal(err) - } - beforeProject := before.Projects[0] - - recorder := registryRequest(t, handler, http.MethodPut, - "/api/workspaces/"+selected.EntryID+"/projects/selected-web", - strings.NewReader(`{"buildVersion":"9.9.9","root":"`+launchRoot+`"}`)) - if recorder.Code != http.StatusConflict { - t.Fatalf("status = %d; want 409; body = %s", recorder.Code, recorder.Body.String()) - } - if !strings.Contains(recorder.Body.String(), `"code":"SERVE_REPOSITORY_READ_ONLY"`) { - t.Fatalf("unexpected error contract: %s", recorder.Body.String()) - } - manifest, err := workspacecore.ReadManifest(selectedRoot) - if err != nil { - t.Fatal(err) - } - if manifest.Projects[0] != beforeProject { - t.Fatal("rejected root injection changed the selected workspace") - } -} - -func TestWorkspacesLegacyMutationPathsAreReadOnlyEvenForIdentityConflict(t *testing.T) { - registry := newRegistryService(t) - rootA := seedRegistryWorkspace(t, "copied-id", "Copy A", "web") - rootB := seedRegistryWorkspace(t, "copied-id", "Copy B", "web") - conflict := observeRegistryWorkspace(t, registry, rootA) - observeRegistryWorkspace(t, registry, rootB) - handler := newRegistryMux(t, "", registry) - beforeA := snapshotRepositoryTree(t, rootA) - beforeB := snapshotRepositoryTree(t, rootB) - - for _, suffix := range []string{"", "/environment"} { - path := "/api/workspaces/" + conflict.EntryID + "/projects/web" + suffix - recorder := registryRequest(t, handler, http.MethodPut, path, strings.NewReader(`{}`)) - if recorder.Code != http.StatusConflict || - !strings.Contains(recorder.Body.String(), `"code":"SERVE_REPOSITORY_READ_ONLY"`) { - t.Fatalf("%s status = %d; body = %s", path, recorder.Code, recorder.Body.String()) - } - } - assertRepositoryUnchanged(t, rootA, beforeA) - assertRepositoryUnchanged(t, rootB, beforeB) -} - -func TestWorkspacesResolveErrorsHaveStableStatuses(t *testing.T) { - registry := newRegistryService(t) - missingRoot := seedRegistryWorkspace(t, "missing-id", "Missing", "web") - missing := observeRegistryWorkspace(t, registry, missingRoot) - if err := os.Remove(filepath.Join(missingRoot, workspacecore.ManifestFilename)); err != nil { - t.Fatal(err) - } - - conflictRootA := seedRegistryWorkspace(t, "copied-id", "Copy A", "web-a") - conflictRootB := seedRegistryWorkspace(t, "copied-id", "Copy B", "web-b") - conflictBeforeA, err := os.ReadFile(filepath.Join(conflictRootA, workspacecore.ManifestFilename)) - if err != nil { - t.Fatal(err) - } - conflictBeforeB, err := os.ReadFile(filepath.Join(conflictRootB, workspacecore.ManifestFilename)) - if err != nil { - t.Fatal(err) - } - conflict := observeRegistryWorkspace(t, registry, conflictRootA) - observeRegistryWorkspace(t, registry, conflictRootB) - handler := newRegistryMux(t, "", registry) - - for _, test := range []struct { - name string - entryID string - wantStatus int - }{ - {name: "unknown", entryID: "wsr_unknown", wantStatus: http.StatusNotFound}, - {name: "missing", entryID: missing.EntryID, wantStatus: http.StatusConflict}, - } { - t.Run(test.name, func(t *testing.T) { - recorder := registryRequest(t, handler, http.MethodGet, - "/api/workspaces/"+test.entryID+"/overview", nil) - if recorder.Code != test.wantStatus { - t.Fatalf("status = %d; want %d; body = %s", - recorder.Code, test.wantStatus, recorder.Body.String()) - } - }) - } - - readRecorder := registryRequest(t, handler, http.MethodGet, - "/api/workspaces/"+conflict.EntryID+"/overview", nil) - if readRecorder.Code != http.StatusOK { - t.Fatalf("identity-conflict read status = %d; body = %s", - readRecorder.Code, readRecorder.Body.String()) - } - profileReadRecorder := registryRequest(t, handler, http.MethodGet, - "/api/workspaces/"+conflict.EntryID+"/environment", nil) - if profileReadRecorder.Code != http.StatusOK { - t.Fatalf("identity-conflict profile read status = %d; body = %s", - profileReadRecorder.Code, profileReadRecorder.Body.String()) - } - writeRecorder := registryRequest(t, handler, http.MethodPut, - "/api/workspaces/"+conflict.EntryID+"/manifest", - strings.NewReader(`{"revision":"test","workspace":{"environment":{"backend":"dotenv"}}}`)) - if writeRecorder.Code != http.StatusConflict { - t.Fatalf("identity-conflict mutation status = %d; want 409; body = %s", - writeRecorder.Code, writeRecorder.Body.String()) - } - conflictAfterA, err := os.ReadFile(filepath.Join(conflictRootA, workspacecore.ManifestFilename)) - if err != nil { - t.Fatal(err) - } - conflictAfterB, err := os.ReadFile(filepath.Join(conflictRootB, workspacecore.ManifestFilename)) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(conflictAfterA, conflictBeforeA) || !bytes.Equal(conflictAfterB, conflictBeforeB) { - t.Fatal("identity-conflict profile request changed a manifest") - } -} - -func TestWorkspacesForgetOnlyRemovesRegistryEntry(t *testing.T) { - registry := newRegistryService(t) - root := seedRegistryWorkspace(t, "forget-id", "Forget", "web") - registered := observeRegistryWorkspace(t, registry, root) - handler := newRegistryMux(t, root, registry) - - recorder := registryRequest(t, handler, http.MethodDelete, - "/api/workspaces/"+registered.EntryID, nil) - if recorder.Code != http.StatusNoContent { - t.Fatalf("status = %d; body = %s", recorder.Code, recorder.Body.String()) - } - if _, err := os.Stat(filepath.Join(root, workspacecore.ManifestFilename)); err != nil { - t.Fatalf("Forget changed the workspace on disk: %v", err) - } - response, err := registry.List(context.Background(), root) - if err != nil { - t.Fatal(err) - } - if len(response.Workspaces) != 0 { - t.Fatalf("registry still contains forgotten entry: %#v", response.Workspaces) - } -} - -func TestWorkspacesNilRegistryKeepsLegacyRoutesAndReturnsEmptyList(t *testing.T) { - root := seedRegistryWorkspace(t, "legacy-id", "Legacy", "web") - handler := newRegistryMux(t, root, nil) - - recorder := registryRequest(t, handler, http.MethodGet, "/api/workspaces", nil) - if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"workspaces": []`) { - t.Fatalf("list status = %d; body = %s", recorder.Code, recorder.Body.String()) - } - - recorder = registryRequest(t, handler, http.MethodGet, "/api/workspace/overview", nil) - if recorder.Code != http.StatusOK { - t.Fatalf("legacy overview status = %d; body = %s", recorder.Code, recorder.Body.String()) - } - var overview workspacecore.Overview - if err := json.Unmarshal(recorder.Body.Bytes(), &overview); err != nil { - t.Fatal(err) - } - if overview.Workspace == nil || overview.Workspace.Name != "Legacy" { - t.Fatalf("legacy overview changed: %#v", overview) - } -} diff --git a/packages/cli/internal/transport/http/helpers_test.go b/packages/cli/internal/transport/http/helpers_test.go deleted file mode 100644 index e6abd908..00000000 --- a/packages/cli/internal/transport/http/helpers_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package serve - -import ( - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/zalando/go-keyring" -) - -func withIsolatedConfig(t *testing.T) { - t.Helper() - tmp := t.TempDir() - t.Setenv("XDG_CONFIG_HOME", tmp) - t.Setenv("HOME", tmp) - keyring.MockInit() -} - -// newTestServer builds a serve.Mux behind httptest.Server and returns the -// server plus its base URL. Caller is responsible for srv.Close(). -func newTestServer(t *testing.T) (*httptest.Server, string) { - t.Helper() - withIsolatedConfig(t) - mux := BuildMux(MuxOpts{ - UIDisabled: true, - ExpectedHosts: nil, // populated below once we know the test addr - SelfOrigin: "", - }) - srv := httptest.NewServer(mux) - t.Cleanup(srv.Close) - // httptest binds 127.0.0.1:. Patch the mux opts the test - // expects: hosts allowlist + self-origin must match the live server. - addr := strings.TrimPrefix(srv.URL, "http://") - mux2 := BuildMux(MuxOpts{ - UIDisabled: true, - ExpectedHosts: map[string]struct{}{addr: {}}, - SelfOrigin: srv.URL, - }) - srv.Config.Handler = mux2 - return srv, srv.URL -} - -// apiRequest issues r against srv and returns response + body bytes for inline -// assertions. Mutations carry the same-origin header required by production. -func apiRequest(t *testing.T, srv *httptest.Server, method, path string, body io.Reader) (*http.Response, []byte) { - t.Helper() - req, err := http.NewRequest(method, srv.URL+path, body) - if err != nil { - t.Fatalf("new request: %v", err) - } - if body != nil { - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Origin", srv.URL) - } - res, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("do: %v", err) - } - defer res.Body.Close() - raw, err := io.ReadAll(res.Body) - if err != nil { - t.Fatalf("read body: %v", err) - } - return res, raw -} diff --git a/packages/cli/pkg/toolchain/adapter.go b/packages/cli/pkg/toolchain/adapter.go index c9b07b69..3d2af51e 100644 --- a/packages/cli/pkg/toolchain/adapter.go +++ b/packages/cli/pkg/toolchain/adapter.go @@ -1,8 +1,7 @@ package toolchain -// Adapter is the contract every toolchain must satisfy. The render -// methods produce string content; callers (internal/adapters, internal/ci) -// decide where on disk to write it. +// Adapter is the contract every toolchain must satisfy for package-manager +// selection and dependency installation planning. // // The CLI composition root registers bundled implementations explicitly. // Register remains public for compatibility with downstream adapters. diff --git a/packages/cli/pkg/toolchain/registry_test.go b/packages/cli/pkg/toolchain/registry_test.go deleted file mode 100644 index 60a77b51..00000000 --- a/packages/cli/pkg/toolchain/registry_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package toolchain_test - -import ( - "sort" - "testing" - - internaltoolchain "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/toolchain" - "github.com/torchstellar-team/one-cli/packages/cli/pkg/toolchain" -) - -func init() { internaltoolchain.RegisterBundled() } - -func TestRegistry_BundledAdaptersPresent(t *testing.T) { - got := toolchain.Registered() - sort.Slice(got, func(i, j int) bool { return got[i] < got[j] }) - want := []toolchain.Toolchain{toolchain.Go, toolchain.Node} - if len(got) < len(want) { - t.Fatalf("registry has %d entries, want at least %d (got=%v)", len(got), len(want), got) - } - // Spot-check the two we ship by default. - for _, id := range want { - a := toolchain.Get(id) - if a == nil { - t.Errorf("Get(%q) returned nil; bundled adapter missing", id) - continue - } - if a.ID() != id { - t.Errorf("Get(%q).ID() = %q, want %q", id, a.ID(), id) - } - } -} - -func TestRegistry_UnknownDefaultsToNode(t *testing.T) { - a := toolchain.Get(toolchain.Toolchain("rust")) - if a == nil { - t.Fatal("Get(unknown) returned nil; expected fallback to Node") - } - if a.ID() != toolchain.Node { - t.Errorf("Get(unknown).ID() = %q, want %q", a.ID(), toolchain.Node) - } -} - -func TestRegistry_RegisterOverwrites(t *testing.T) { - // Save existing for restore. - original := toolchain.Get(toolchain.Node) - - stub := stubAdapter{id: toolchain.Node} - toolchain.Register(stub) - if got := toolchain.Get(toolchain.Node); got.ID() != toolchain.Node { - t.Errorf("expected stub id == Node, got %q", got.ID()) - } - // Different concrete type than the original confirms overwrite. - if _, isStub := toolchain.Get(toolchain.Node).(stubAdapter); !isStub { - t.Errorf("Register did not overwrite existing entry") - } - - // Restore so other tests aren't affected. - toolchain.Register(original) -} - -type stubAdapter struct{ id toolchain.Toolchain } - -func (s stubAdapter) ID() toolchain.Toolchain { return s.id } -func (stubAdapter) UsesPackageManager() bool { return false } -func (stubAdapter) InstallPlan(toolchain.PlanInput) toolchain.CommandStep { - return toolchain.CommandStep{} -} -func (stubAdapter) PackageManagerForManifest(p toolchain.PackageManager) toolchain.PackageManager { - return p -} diff --git a/packages/cli/pkg/toolchain/types.go b/packages/cli/pkg/toolchain/types.go index 4ba43288..71c1b3c5 100644 --- a/packages/cli/pkg/toolchain/types.go +++ b/packages/cli/pkg/toolchain/types.go @@ -1,13 +1,12 @@ // Package toolchain is the public contract for one-cli toolchain adapters. // -// Adapters generate Dockerfiles, GitHub Actions workflows, and runtime -// resolution (entrypoint command + container port) for newly-added -// subprojects. Each language family ships one adapter. +// Adapters describe package-manager requirements and dependency installation +// for newly added projects. Each language family ships one adapter. // // Stability: this package's exported surface is the integration point // for future external extensions. Implementations live in -// internal/toolchain/, which the CLI composition root registers explicitly -// for side-effects from internal/bootstrap/cli. +// internal/adapters/toolchain/, which the CLI composition root registers +// explicitly from internal/bootstrap/cli. package toolchain // Toolchain is the canonical id stamped into the registry / manifest. diff --git a/packages/cli/testdata/dashboard-dev-workspace/one.manifest.toml b/packages/cli/testdata/dashboard-dev-workspace/one.manifest.toml deleted file mode 100644 index eefaa8e7..00000000 --- a/packages/cli/testdata/dashboard-dev-workspace/one.manifest.toml +++ /dev/null @@ -1,21 +0,0 @@ -version = 2 - -[workspace] -id = "one-dashboard-dev" -name = "Dashboard Development" - -[projects.web] -path = "apps/web" -toolchain = "node" - -[projects.api] -path = "services/api" -toolchain = "go" - -[projects.docs] -path = "apps/docs" -toolchain = "node" - -[projects.shared] -path = "packages/shared" -toolchain = "node" diff --git a/packages/cli/testdata/reference/add-go-api.json b/packages/cli/testdata/reference/add-go-api.json deleted file mode 100644 index 0ad0bcd3..00000000 --- a/packages/cli/testdata/reference/add-go-api.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "schema": "one-cli/add/v1", - "subproject_name": "user-api", - "target_path": "\u003cvolatile\u003e", - "template_id": "go-api", - "toolchain": "go" -} diff --git a/packages/cli/testdata/reference/create-default.json b/packages/cli/testdata/reference/create-default.json deleted file mode 100644 index 0f0db897..00000000 --- a/packages/cli/testdata/reference/create-default.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "created_in_place": false, - "created_path": "\u003cvolatile\u003e", - "dev_enabled": true, - "package_manager": "", - "project_name": "my-app", - "schema": "one-cli/create/v2", - "secrets_backend": "infisical" -} diff --git a/packages/cli/testdata/reference/create-non-empty-error.json b/packages/cli/testdata/reference/create-non-empty-error.json deleted file mode 100644 index ada1518f..00000000 --- a/packages/cli/testdata/reference/create-non-empty-error.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "error": { - "code": "EXISTING_TARGET_NOT_EMPTY", - "context": { - "conflicting_entries": [ - "preexisting.txt" - ], - "display_path": "\u003cvolatile\u003e", - "target_path": "\u003cvolatile\u003e" - }, - "message": "\u003cvolatile\u003e", - "remediation": [ - { - "action": "use-different-dir", - "hint": "Choose another empty directory, for example one create my-app." - } - ] - }, - "schema": "one-cli/error/v1" -} diff --git a/packages/cli/testdata/reference/error-template-not-found.json b/packages/cli/testdata/reference/error-template-not-found.json deleted file mode 100644 index fc94b2c9..00000000 --- a/packages/cli/testdata/reference/error-template-not-found.json +++ /dev/null @@ -1 +0,0 @@ -{"schema":"one-cli/error/v1","error":{"code":"NOT_ONE_PROJECT","message":"未检测到 One CLI 项目,请在项目根目录执行(缺少 \"one\" 标记)。","context":{"cwd":"","package_json_path":"","missing":"one_marker"},"remediation":[{"action":"cd-to-workspace","hint":"切换到 One workspace 根目录后重试(package.json 中需有 \"one\" 字段)"},{"action":"specify-dir","hint":"通过 -d 参数显式指定工作区目录","command":"one -d /path/to/workspace"}]}} diff --git a/packages/cli/testdata/reference/error-template-required.json b/packages/cli/testdata/reference/error-template-required.json deleted file mode 100644 index fc94b2c9..00000000 --- a/packages/cli/testdata/reference/error-template-required.json +++ /dev/null @@ -1 +0,0 @@ -{"schema":"one-cli/error/v1","error":{"code":"NOT_ONE_PROJECT","message":"未检测到 One CLI 项目,请在项目根目录执行(缺少 \"one\" 标记)。","context":{"cwd":"","package_json_path":"","missing":"one_marker"},"remediation":[{"action":"cd-to-workspace","hint":"切换到 One workspace 根目录后重试(package.json 中需有 \"one\" 字段)"},{"action":"specify-dir","hint":"通过 -d 参数显式指定工作区目录","command":"one -d /path/to/workspace"}]}} diff --git a/packages/cli/testdata/reference/error-unknown-command.json b/packages/cli/testdata/reference/error-unknown-command.json deleted file mode 100644 index 6308bd2f..00000000 --- a/packages/cli/testdata/reference/error-unknown-command.json +++ /dev/null @@ -1 +0,0 @@ -{"schema":"one-cli/error/v1","error":{"code":"UNKNOWN_COMMAND","message":"Unknown command: __unknown-cmd","context":{"command":"one __unknown-cmd"},"remediation":[{"action":"show-help","hint":"Show available commands","command":"one --help"}]}} diff --git a/packages/cli/testdata/reference/help/add.txt b/packages/cli/testdata/reference/help/add.txt deleted file mode 100644 index 6a5ab64a..00000000 --- a/packages/cli/testdata/reference/help/add.txt +++ /dev/null @@ -1,21 +0,0 @@ - -DESCRIPTION -Add a project from a technology stack - -USAGE - one add [template-id] [flags] - -EXAMPLES - one add - one add react-spa --name web --yes - one add empty-app --name web --yes - one add empty-service --name api --yes - -TIPS -Add a project from a template, or start empty and configure a technology stack later. Installs missing workspace and technology-stack skills after generation; use --skip-skills to skip. Business skills are installed manually. - -COMMON OPTIONS - -n, --name Project name - -o, --output Output format: json | yaml | text (default: terminal-friendly text, JSON when piped) - --skip-skills Skip automatic installation of workspace and technology-stack skills - -y, --yes Run non-interactively diff --git a/packages/cli/testdata/reference/help/create.txt b/packages/cli/testdata/reference/help/create.txt deleted file mode 100644 index 9eadb3e2..00000000 --- a/packages/cli/testdata/reference/help/create.txt +++ /dev/null @@ -1,22 +0,0 @@ - -DESCRIPTION -Create a workspace - -USAGE - one create [dir] [flags] - -EXAMPLES - one create demo - one create . - one create demo --yes - -TIPS -Create an empty workspace named after its target folder. Empty directories and empty Git repositories are supported. Run one add afterward to add a project. Installs one-cli usage and find-skills discovery guidance in the workspace's .agents/skills by default; use --skip-skills to skip. Business skills are installed manually. - -COMMON OPTIONS - -o, --output Output format: json | yaml | text (default: terminal-friendly text, JSON when piped) - --skip-skills Skip automatic installation of workspace and technology-stack skills - -y, --yes Use defaults without prompting - -AUTOMATION AND ADVANCED OPTIONS - -n, --name Workspace name (default: target directory name) diff --git a/packages/cli/testdata/reference/help/env.txt b/packages/cli/testdata/reference/help/env.txt deleted file mode 100644 index d8409193..00000000 --- a/packages/cli/testdata/reference/help/env.txt +++ /dev/null @@ -1,26 +0,0 @@ - -DESCRIPTION -Manage environment variables - -USAGE - one env [flags] - -SUBCOMMANDS - bind Create or bind an Infisical project - list List environment-variable names - set Write a variable to Infisical - unset Delete an Infisical environment variable - -EXAMPLES - one env - one env set DATABASE_URL - one env list - -TIPS -Shows the environment-variable source and scope, or sets, deletes, and lists variables. list shows names only. Secret values are never displayed; use one exec to inject them into a command. - -COMMON OPTIONS - --env Environment name - --global Manage Infisical shared credentials, including outside a workspace - -o, --output Output format: json | yaml | text (default: terminal-friendly text, JSON when piped) - --path Shared credential folder (current level only, not recursive) diff --git a/packages/cli/testdata/reference/help/env_bind.txt b/packages/cli/testdata/reference/help/env_bind.txt deleted file mode 100644 index 8bfb3369..00000000 --- a/packages/cli/testdata/reference/help/env_bind.txt +++ /dev/null @@ -1,27 +0,0 @@ - -DESCRIPTION -Create or bind an Infisical project - -USAGE - one env bind [flags] - -EXAMPLES - one env bind - one env bind --create - one env bind --project-id PROJECT_ID - one env bind --global - -TIPS -Sign in with one login. - -Run one env bind to select an existing project or create a workspace project interactively. Use --create to explicitly create and bind storage, or --project-id to bind an existing Secret Manager project. Existing bindings are preserved. Workspace bindings are saved to one.manifest.toml, with default environment dev. Without a binding, set fails instead of creating a project. - -Run one env bind --global to initialize shared credential storage. Without a saved binding, creates or reuses shared-credentials with default environment dev. No project selection or ID is required. Existing bindings are preserved; --env changes the default environment. Shared credential binding metadata is saved in local configuration. --create is only available for workspace binding and cannot be combined with --global. - -COMMON OPTIONS - --create Workspace only: create and bind the Infisical project; preserve an existing binding - --env Default environment - --global Manage Infisical shared credentials, including outside a workspace - -o, --output Output format: json | yaml | text (default: terminal-friendly text, JSON when piped) - --path Shared credential folder (current level only, not recursive) - --project-id Existing Infisical project ID diff --git a/packages/cli/testdata/reference/help/env_list.txt b/packages/cli/testdata/reference/help/env_list.txt deleted file mode 100644 index 69499055..00000000 --- a/packages/cli/testdata/reference/help/env_list.txt +++ /dev/null @@ -1,13 +0,0 @@ - -DESCRIPTION -List environment-variable names - -USAGE - one env list [flags] - -COMMON OPTIONS - --env Environment name (default: dev) - --global Manage Infisical shared credentials, including outside a workspace - -o, --output Output format: json | yaml | text (default: terminal-friendly text, JSON when piped) - --path Shared credential folder (current level only, not recursive) - -p, --project Project name or relative path; defaults to the current project diff --git a/packages/cli/testdata/reference/help/env_set.txt b/packages/cli/testdata/reference/help/env_set.txt deleted file mode 100644 index 64cc8b55..00000000 --- a/packages/cli/testdata/reference/help/env_set.txt +++ /dev/null @@ -1,18 +0,0 @@ - -DESCRIPTION -Write a variable to Infisical - -USAGE - one env set [VALUE] [flags] - -TIPS -Sets one environment variable. Bind the workspace to Infisical with one env bind first; an unbound workspace fails without creating a project. Use `one env set KEY` for hidden interactive input, or pass `KEY VALUE` / `KEY=VALUE` in automation. - -COMMON OPTIONS - --env Environment name (default: dev) - --global Manage Infisical shared credentials, including outside a workspace - -o, --output Output format: json | yaml | text (default: terminal-friendly text, JSON when piped) - --path Shared credential folder (current level only, not recursive) - -p, --project Project name or relative path; defaults to the current project - --stdin Read a project variable or shared credential from standard input to keep it out of shell history - -y, --yes Confirm overwrites and new environments non-interactively diff --git a/packages/cli/testdata/reference/help/env_unset.txt b/packages/cli/testdata/reference/help/env_unset.txt deleted file mode 100644 index 48f52bf1..00000000 --- a/packages/cli/testdata/reference/help/env_unset.txt +++ /dev/null @@ -1,13 +0,0 @@ - -DESCRIPTION -Delete an Infisical environment variable - -USAGE - one env unset [flags] - -COMMON OPTIONS - --env Environment name - --global Manage Infisical shared credentials, including outside a workspace - -o, --output Output format: json | yaml | text (default: terminal-friendly text, JSON when piped) - --path Shared credential folder (current level only, not recursive) - -p, --project Project name or path diff --git a/packages/cli/testdata/reference/help/exec.txt b/packages/cli/testdata/reference/help/exec.txt deleted file mode 100644 index 59b233c8..00000000 --- a/packages/cli/testdata/reference/help/exec.txt +++ /dev/null @@ -1,24 +0,0 @@ - -DESCRIPTION -Load environment variables and run a command - -USAGE - one exec [project] [-p ] [--env ] -- [args...] - -TIPS -Runs a command in the selected project directory. Select a project by name or relative path, or run from inside a project. The positional project and --project must agree when both are used. - -Use -- to separate One options from the child command. When the workspace has an env.infisical binding, variables are fetched from Infisical and injected into the child process. --env selects the environment. Injected values override same-name shell variables. Without a binding, the shell environment is used directly. Local .env files are not loaded by One. - -With --global, provide --env and --path explicitly. The command keeps the current working directory. Use --keys to select shared credentials. - -One does not print injected variable values. Child stdout and stderr pass through unchanged. - -COMMON OPTIONS - --dry-run Print the execution plan without loading environment values or starting a command - --env Environment name (default: dev) - --global Use shared credentials, keeping the current working directory - --keys Inject only the specified variable names, separated by commas - -o, --output Output format: json | yaml | text (default: terminal-friendly text, JSON when piped) - --path Shared credential folder (must be explicit) - -p, --project Project name or relative path; inferred from the current directory by default diff --git a/packages/cli/testdata/reference/help/hk.txt b/packages/cli/testdata/reference/help/hk.txt deleted file mode 100644 index b50e38ad..00000000 --- a/packages/cli/testdata/reference/help/hk.txt +++ /dev/null @@ -1,18 +0,0 @@ - -DESCRIPTION -Run workspace checks and fixes with hk through One - -USAGE - one hk [args...] [flags] - -EXAMPLES - one hk check - one hk check --all - one hk fix - one hk validate - -TIPS -Use one hk check for changed files, one hk check --all for CI, and one hk fix for explicit fixes. Git hook installation and migration are managed by one init hooks. hk and its tool environment run through the mise selected by One (system first, otherwise downloaded and managed by One); application dependencies remain project-specific. - -COMMON OPTIONS - -o, --output Output format: json | yaml | text (default: terminal-friendly text, JSON when piped) diff --git a/packages/cli/testdata/reference/help/init.txt b/packages/cli/testdata/reference/help/init.txt deleted file mode 100644 index 2d4e6932..00000000 --- a/packages/cli/testdata/reference/help/init.txt +++ /dev/null @@ -1,13 +0,0 @@ - -DESCRIPTION -Generate workspace tool configuration - -USAGE - one init [flags] - -SUBCOMMANDS - hooks Generate hk checks, migrate default Husky hooks, and install local Git launchers - mise Generate or refresh optional mise tool and task configuration - -COMMON OPTIONS - -o, --output Output format: json | yaml | text (default: terminal-friendly text, JSON when piped) diff --git a/packages/cli/testdata/reference/help/init_hooks.txt b/packages/cli/testdata/reference/help/init_hooks.txt deleted file mode 100644 index 5eacf20e..00000000 --- a/packages/cli/testdata/reference/help/init_hooks.txt +++ /dev/null @@ -1,15 +0,0 @@ - -DESCRIPTION -Generate hk checks, migrate default Husky hooks, and install local Git launchers - -USAGE - one init hooks [flags] - -EXAMPLES - one init hooks --dry-run -o json - one init hooks - one hk check --all - -COMMON OPTIONS - --dry-run Preview configuration, migration, and local hook files without changing anything - -o, --output Output format: json | yaml | text (default: terminal-friendly text, JSON when piped) diff --git a/packages/cli/testdata/reference/help/init_mise.txt b/packages/cli/testdata/reference/help/init_mise.txt deleted file mode 100644 index 22a45750..00000000 --- a/packages/cli/testdata/reference/help/init_mise.txt +++ /dev/null @@ -1,17 +0,0 @@ - -DESCRIPTION -Generate or refresh optional mise tool and task configuration - -USAGE - one init mise [flags] - -EXAMPLES - one init mise --dry-run -o json - one init mise - one run dev -p web - -COMMON OPTIONS - --dry-run Print proposed file contents without writing files or executing mise - --go-version Exact workspace Go version (default: existing root pin or highest module requirement) - --node-version Exact Node version (default: previous generated version or 24.21.0) - -o, --output Output format: json | yaml | text (default: terminal-friendly text, JSON when piped) diff --git a/packages/cli/testdata/reference/help/locale.txt b/packages/cli/testdata/reference/help/locale.txt deleted file mode 100644 index bb6c847a..00000000 --- a/packages/cli/testdata/reference/help/locale.txt +++ /dev/null @@ -1,14 +0,0 @@ - -DESCRIPTION -Show or set the display language (auto / zh-CN / en-US) - -USAGE - one locale [auto|zh-CN|en-US] [flags] - -TIPS -Shows the saved language preference and the language active in this terminal. Pass auto, zh-CN, or en-US to change the preference. - -auto follows LC_ALL, LC_MESSAGES, then LANG; the fallback is English. The Dashboard shares this preference. Project and third-party tool logs keep their original language. - -COMMON OPTIONS - -o, --output Output format: json | yaml | text (default: terminal-friendly text, JSON when piped) diff --git a/packages/cli/testdata/reference/help/login.txt b/packages/cli/testdata/reference/help/login.txt deleted file mode 100644 index cedfab1c..00000000 --- a/packages/cli/testdata/reference/help/login.txt +++ /dev/null @@ -1,10 +0,0 @@ - -DESCRIPTION -Sign in to Infisical in your browser - -USAGE - one login [flags] - -COMMON OPTIONS - -o, --output Output format: json | yaml | text (default: terminal-friendly text, JSON when piped) - --site-url Infisical instance root URL diff --git a/packages/cli/testdata/reference/help/logout.txt b/packages/cli/testdata/reference/help/logout.txt deleted file mode 100644 index 6d6e0efd..00000000 --- a/packages/cli/testdata/reference/help/logout.txt +++ /dev/null @@ -1,9 +0,0 @@ - -DESCRIPTION -Sign out of Infisical on this machine, keeping variable locations - -USAGE - one logout [flags] - -COMMON OPTIONS - -o, --output Output format: json | yaml | text (default: terminal-friendly text, JSON when piped) diff --git a/packages/cli/testdata/reference/help/mise.txt b/packages/cli/testdata/reference/help/mise.txt deleted file mode 100644 index 0f1563f9..00000000 --- a/packages/cli/testdata/reference/help/mise.txt +++ /dev/null @@ -1,17 +0,0 @@ - -DESCRIPTION -Run mise commands using the runtime selected by One - -USAGE - one mise [args...] [flags] - -EXAMPLES - one mise trust - one mise doctor - one mise exec -- pnpm install - -TIPS -Optional access to mise for configuration trust, diagnostics, and tool installation. One first uses a compatible mise on PATH. Otherwise it reuses or downloads a verified, pinned runtime into its own directories; first use without a local runtime requires network access. Arguments and output are passed through; project environment secrets are not loaded by One here. Normal development continues to use one dev and one exec. - -COMMON OPTIONS - -o, --output Output format: json | yaml | text (default: terminal-friendly text, JSON when piped) diff --git a/packages/cli/testdata/reference/help/root.txt b/packages/cli/testdata/reference/help/root.txt deleted file mode 100644 index dc521a01..00000000 --- a/packages/cli/testdata/reference/help/root.txt +++ /dev/null @@ -1,29 +0,0 @@ - -one — workspace development - -USAGE - one [options] - -EVERYDAY COMMANDS - create Create a workspace - add Add a project - run List and execute workspace tasks - exec Execute a command with project variables - env Manage environment variables - login Sign in to Infisical in your browser - -COMMON OPTIONS - -o, --output Output format: json | yaml | text - -h, --help Show help - -v, --version Show version - -EXAMPLES - one create demo - one add - one dev - one build -p web - one test -p web - -All commands: one help --all -Task shorthand: one = one run (built-ins take precedence) -Command help: one --help diff --git a/packages/cli/testdata/reference/help/run.txt b/packages/cli/testdata/reference/help/run.txt deleted file mode 100644 index ff86f73c..00000000 --- a/packages/cli/testdata/reference/help/run.txt +++ /dev/null @@ -1,26 +0,0 @@ - -DESCRIPTION -Run workspace tasks with Process Compose - -USAGE - one run [task] [flags] - -EXAMPLES - one run - one run build -p web - one run test -p api - one run build --dry-run - -TIPS -Lists tasks with no arguments. Runs supported static tasks from mise.toml through official Process Compose; mise prepares tool versions. one is shorthand for one run . Built-in commands take precedence. One TUI shows running tasks above the full dependency tree. Tab switches focus; y copies logs; c enables native terminal copying. - -COMMON OPTIONS - --concurrency Compatibility flag; limits below the number of command tasks are unsupported by Process Compose - --dry-run Preview tasks without preparing tools, dependencies, secrets, or caches - --env One project environment name - --force Bypass freshness checks and execute tasks - --list List available tasks - -o, --output Output format: json | yaml | text (default: terminal-friendly text, JSON when piped) - -p, --project Project name or relative path (repeatable) - --ui Output mode: auto, stream, tui, or raw - --verbose Show task sources, cache and interactive settings when listing tasks diff --git a/packages/cli/testdata/reference/help/serve.txt b/packages/cli/testdata/reference/help/serve.txt deleted file mode 100644 index 2a45348a..00000000 --- a/packages/cli/testdata/reference/help/serve.txt +++ /dev/null @@ -1,17 +0,0 @@ - -DESCRIPTION -Open the local Dashboard - -USAGE - one serve [flags] - -TIPS -Starts a local Dashboard for workspaces, development services, project configuration, Infisical login, and shared credentials. The project Run tab provides start, stop, restart, a live console, and local access links. Review manifest changes before saving. Plaintext variables are fetched only when viewed or copied. - -By default, binds to 127.0.0.1 on an available port and opens your browser. Closing the page keeps services running; Ctrl-C stops the Dashboard and its services. Host and Origin checks protect the interface. - -COMMON OPTIONS - --host Loopback host for the local settings page - --open Open the settings page in the default browser - -o, --output Output format: json | yaml | text (default: terminal-friendly text, JSON when piped) - --port Local port (0 chooses an available port) diff --git a/packages/cli/testdata/reference/help/skills.txt b/packages/cli/testdata/reference/help/skills.txt deleted file mode 100644 index 27bbf72b..00000000 --- a/packages/cli/testdata/reference/help/skills.txt +++ /dev/null @@ -1,23 +0,0 @@ - -DESCRIPTION -Forward commands to the upstream Skills CLI - -USAGE - one skills [args...] [flags] - -EXAMPLES - one skills find react - one skills add owner/repo --skill skill-name - one skills list - one skills update - -TIPS -Run npx skills@1.7.0 with all arguments unchanged in the current directory. -One prepares Node when it is missing and preserves upstream interaction, output, -and exit codes. Use one skills --help for upstream help. - -Business skills are installed only on request from their GitHub repositories. -One does not add --global, --agent, or the Skills CLI --yes flag to manual commands. - -COMMON OPTIONS - -o, --output Output format: json | yaml | text (default: terminal-friendly text, JSON when piped) diff --git a/packages/cli/testdata/reference/help/templates.txt b/packages/cli/testdata/reference/help/templates.txt deleted file mode 100644 index 7ec27f2b..00000000 --- a/packages/cli/testdata/reference/help/templates.txt +++ /dev/null @@ -1,12 +0,0 @@ - -DESCRIPTION -List available technology stacks - -USAGE - one templates [flags] - -SUBCOMMANDS - list List available technology stacks - -COMMON OPTIONS - -o, --output Output format: json | yaml | text (default: terminal-friendly text, JSON when piped) diff --git a/packages/cli/testdata/reference/help/templates_list.txt b/packages/cli/testdata/reference/help/templates_list.txt deleted file mode 100644 index 51d6eb0b..00000000 --- a/packages/cli/testdata/reference/help/templates_list.txt +++ /dev/null @@ -1,9 +0,0 @@ - -DESCRIPTION -List available technology stacks - -USAGE - one templates list [flags] - -COMMON OPTIONS - -o, --output Output format: json | yaml | text (default: terminal-friendly text, JSON when piped) diff --git a/packages/cli/testdata/reference/help/upgrade.txt b/packages/cli/testdata/reference/help/upgrade.txt deleted file mode 100644 index 74802b83..00000000 --- a/packages/cli/testdata/reference/help/upgrade.txt +++ /dev/null @@ -1,22 +0,0 @@ - -DESCRIPTION -Update One CLI to the latest stable release - -USAGE - one upgrade [flags] - -EXAMPLES - one upgrade - one upgrade -o json - -TIPS -Check for the latest stable One CLI release and update the current executable. -Downloads are verified with SHA256 and a version check. An equal or newer -installed version is kept. - -Manual updates check immediately, including in CI and JSON/YAML output. -Only official stable release builds can upgrade. On Windows, the verified -update is installed after this command exits. - -COMMON OPTIONS - -o, --output Output format: json | yaml | text (default: terminal-friendly text, JSON when piped) diff --git a/packages/cli/testdata/reference/help/whoami.txt b/packages/cli/testdata/reference/help/whoami.txt deleted file mode 100644 index 449d304a..00000000 --- a/packages/cli/testdata/reference/help/whoami.txt +++ /dev/null @@ -1,9 +0,0 @@ - -DESCRIPTION -Show Infisical login status without displaying tokens - -USAGE - one whoami [flags] - -COMMON OPTIONS - -o, --output Output format: json | yaml | text (default: terminal-friendly text, JSON when piped) diff --git a/packages/cli/testdata/reference/serve.json b/packages/cli/testdata/reference/serve.json deleted file mode 100644 index 3be55ff8..00000000 --- a/packages/cli/testdata/reference/serve.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "host": "127.0.0.1", - "port": "\u003cvolatile\u003e", - "schema": "one-cli/serve/v2", - "status": "listening", - "url": "\u003cvolatile\u003e" -} diff --git a/packages/cli/testdata/reference/templates.json b/packages/cli/testdata/reference/templates.json deleted file mode 100644 index f45010ff..00000000 --- a/packages/cli/testdata/reference/templates.json +++ /dev/null @@ -1,192 +0,0 @@ -{ - "schema": "one-cli/templates/v1", - "templates": [ - { - "category": "backend", - "description": "NestJS + TypeScript,适合 API 服务与业务后台", - "id": "nestjs-api", - "name": "NestJS API 服务", - "repo": "local:nestjs-api", - "tags": [ - "api", - "nestjs", - "typescript", - "backend" - ], - "toolchain": "node" - }, - { - "category": "backend", - "description": "Gin + Gorm + Viper + Zap,适合 API 服务与业务后台", - "id": "go-api", - "name": "Go API 服务", - "repo": "local:go-api", - "tags": [ - "api", - "go", - "golang", - "gin", - "gorm", - "viper", - "zap", - "backend" - ], - "toolchain": "go" - }, - { - "category": "frontend", - "description": "Next.js + React 静态导出,适合官网、营销页与内容站点", - "id": "nextjs-site", - "name": "Next.js 静态网站", - "repo": "local:nextjs-site", - "tags": [ - "web", - "nextjs", - "react", - "static", - "frontend" - ], - "toolchain": "node" - }, - { - "category": "frontend", - "description": "Next.js + Fumadocs + Base UI,支持中英文文档与静态搜索", - "id": "fumadocs-docs", - "name": "Fumadocs 文档站点", - "repo": "local:fumadocs-docs", - "tags": [ - "web", - "nextjs", - "react", - "docs", - "frontend" - ], - "toolchain": "node" - }, - { - "category": "frontend", - "description": "Next.js 前端模板,适合 SEO 与首屏性能要求较高场景", - "id": "nextjs-app", - "name": "Next.js 全栈应用", - "repo": "local:nextjs-app", - "tags": [ - "web", - "nextjs", - "react", - "frontend" - ], - "toolchain": "node" - }, - { - "category": "frontend", - "description": "React + Vite 单页应用模板,适合交互型业务系统", - "id": "react-spa", - "name": "React 单页应用", - "repo": "local:react-spa", - "tags": [ - "web", - "spa", - "react", - "vite", - "frontend" - ], - "toolchain": "node" - }, - { - "category": "frontend", - "description": "React Native + Expo App 模板,适合 iOS/Android 双端开发", - "id": "expo-mobile", - "name": "Expo 移动 App", - "repo": "local:expo-mobile", - "tags": [ - "react-native", - "expo", - "mobile", - "app" - ], - "toolchain": "node" - }, - { - "category": "library", - "description": "通用库模板,适合发包场景,内置 tsdown + vitest + oxlint/oxfmt", - "id": "ts-library", - "name": "TypeScript 通用库", - "repo": "local:ts-library", - "tags": [ - "library", - "typescript", - "npm", - "tsdown", - "vitest" - ], - "toolchain": "node" - }, - { - "category": "library", - "description": "Go 库模板,遵循 golang-standards/project-layout,适合发包与被 import 场景", - "id": "go-lib", - "name": "Go 通用库", - "repo": "local:go-lib", - "tags": [ - "library", - "go", - "golang", - "module", - "pkg" - ], - "toolchain": "go" - }, - { - "category": "frontend", - "description": "Electron + React + Vite 桌面应用模板,生成 renderer、main、preload 三个顶层项目,主进程使用 Awilix 工厂函数", - "id": "electron-app", - "name": "Electron 桌面应用", - "repo": "local:electron-app", - "tags": [ - "electron", - "desktop", - "react", - "vite", - "frontend" - ], - "toolchain": "node" - }, - { - "category": "frontend", - "description": "在 apps/ 下创建空项目,稍后自行配置技术栈", - "id": "empty-app", - "name": "空应用", - "repo": "local:empty-app", - "tags": [ - "empty", - "frontend" - ], - "toolchain": "none" - }, - { - "category": "backend", - "description": "在 services/ 下创建空项目,稍后自行配置技术栈", - "id": "empty-service", - "name": "空服务", - "repo": "local:empty-service", - "tags": [ - "empty", - "backend" - ], - "toolchain": "none" - }, - { - "category": "library", - "description": "在 packages/ 下创建空项目,稍后自行配置技术栈", - "id": "empty-library", - "name": "空共享库", - "repo": "local:empty-library", - "tags": [ - "empty", - "library" - ], - "toolchain": "none" - } - ], - "total": 13 -} diff --git a/packages/cli/testdata/reference/version.txt b/packages/cli/testdata/reference/version.txt deleted file mode 100644 index 71790396..00000000 --- a/packages/cli/testdata/reference/version.txt +++ /dev/null @@ -1 +0,0 @@ -0.2.3 diff --git a/packages/cli/tests/e2e/agents_locale_test.go b/packages/cli/tests/e2e/agents_locale_test.go deleted file mode 100644 index 91ffdaf3..00000000 --- a/packages/cli/tests/e2e/agents_locale_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package cli_test - -import ( - "bytes" - "os" - "path/filepath" - "testing" -) - -func TestCreateAgentGuidanceUsesResolvedLocaleAndRemainsTeamOwned(t *testing.T) { - for _, tc := range []struct { - name, preference, terminal, want string - }{ - {"saved Chinese overrides terminal", "zh-CN", "en_US.UTF-8", "zh-CN"}, - {"saved English overrides terminal", "en-US", "zh_CN.UTF-8", "en-US"}, - {"automatic Chinese", "auto", "zh_CN.UTF-8", "zh-CN"}, - {"automatic fallback", "auto", "de_DE.UTF-8", "en-US"}, - } { - t.Run(tc.name, func(t *testing.T) { - tmp := t.TempDir() - isolateHome(t, tmp) - t.Setenv("LC_ALL", tc.terminal) - if _, stderr, code := runBinary(t, "locale", tc.preference, "-o", "json"); code != 0 { - t.Fatalf("set language: exit=%d %s", code, stderr) - } - root := bootstrapWorkspace(t, tmp, "demo") - path := filepath.Join(root, "AGENTS.md") - got, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - want, err := os.ReadFile(filepath.Join(repoRoot(t), "internal", "modules", "creation", "templates", "AGENTS."+tc.want+".md")) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(got, want) { - t.Fatalf("AGENTS.md does not match the %s Markdown template", tc.want) - } - other := "zh-CN" - if tc.want == "zh-CN" { - other = "en-US" - } - if _, stderr, code := runBinary(t, "locale", other, "-o", "json"); code != 0 { - t.Fatalf("switch language: exit=%d %s", code, stderr) - } - if _, stderr, code := runBinaryIn(t, root, "add", "empty-app", "--name", "web", "--yes", "-o", "json"); code != 0 { - t.Fatalf("add after switching language: exit=%d %s", code, stderr) - } - after, err := os.ReadFile(path) - if err != nil || !bytes.Equal(got, after) { - t.Fatalf("language switch or one add rewrote existing guidance: %v", err) - } - }) - } -} diff --git a/packages/cli/tests/e2e/build_test.go b/packages/cli/tests/e2e/build_test.go deleted file mode 100644 index 0e8f8589..00000000 --- a/packages/cli/tests/e2e/build_test.go +++ /dev/null @@ -1,227 +0,0 @@ -package cli_test - -import ( - "context" - "encoding/json" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/miseconfig" - buildmodule "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/tasks" - "gopkg.in/yaml.v3" -) - -func buildWrite(t *testing.T, root, path, value string) { - t.Helper() - path = filepath.Join(root, path) - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte(value), 0755); err != nil { - t.Fatal(err) - } -} - -// Fake package tools make dependency and runtime integration deterministic and -// offline while the real One binary executes the full build -> run pipeline. -func buildFixture(t *testing.T, mise bool) string { - t.Helper() - if runtime.GOOS == "windows" { - t.Skip("POSIX fake package tools") - } - misePath, err := exec.LookPath("mise") - if pinned := os.Getenv("ONE_TEST_MISE_BINARY"); pinned != "" { - misePath, err = pinned, nil - } - if err != nil { - t.Skip("real mise is required for task integration") - } - compose, err := exec.Command(misePath, "which", "process-compose", "--tool", "process-compose@1.122.0").Output() - if err != nil { - t.Skipf("official pinned Process Compose is required: %v", err) - } - composePath := strings.TrimSpace(string(compose)) - root := t.TempDir() - // mise does not discover a monorepo rooted at HOME. - isolateHome(t, t.TempDir()) - t.Setenv("ONE_RUNTIME", "builtin") - t.Setenv("MISE_EXPERIMENTAL", "0") - // Nested mise may select a different Go binary than the parent test task. - // Let that binary discover its own matching standard library. - t.Setenv("GOROOT", "") - buildWrite(t, root, "one.manifest.toml", `version = 2 - -[workspace] -id = "build-test" -name = "build-test" - -[projects."web"] -path = "apps/web" -toolchain = "node" - -[projects."lib"] -path = "packages/lib" -toolchain = "node" - -[projects."mobile"] -path = "apps/mobile" -toolchain = "node" -`) - buildWrite(t, root, "package.json", `{"packageManager":"pnpm@12.3.4"}`) - buildWrite(t, root, "apps/web/package.json", `{"name":"@build/web","scripts":{"build":"sh build.sh"},"dependencies":{"@build/lib":"workspace:*"}}`) - buildWrite(t, root, "packages/lib/package.json", `{"name":"@build/lib","scripts":{"build":"sh build.sh"}}`) - buildWrite(t, root, "apps/mobile/package.json", `{"name":"@build/mobile","scripts":{}}`) - buildWrite(t, root, "packages/lib/build.sh", "#!/bin/sh\necho lib >> ../../order\necho built > artifact\nprintf 'library-output\\n'\n") - buildWrite(t, root, "apps/web/build.sh", "#!/bin/sh\n[ -f ../../packages/lib/artifact ] || exit 93\necho web >> ../../order\nprintf 'web-env=%s runtime=%s\\n' \"$BUILD_VALUE\" \"${ONE_MISE_ONLY:-builtin}\"\n") - buildWrite(t, root, "apps/web/.env", "BUILD_VALUE=base\n") - buildWrite(t, root, "apps/web/.env.prod", "BUILD_VALUE=production\n") - buildWrite(t, root, "tools/pnpm", "#!/bin/sh\ncase \"$1\" in\n--version) echo 12.3.4;;\ninstall) mkdir -p node_modules; echo installed >> installs;;\nrun) script=$2; shift 2; exec sh \"$script.sh\" \"$@\";;\n*) exit 95;;\nesac\n") - buildWrite(t, root, "tools/node", "#!/bin/sh\necho v24.15.0\n") - // One prepends the selected mise binary's directory to PATH. Keep it with - // the fixture tools so a host pnpm cannot override the fake package manager. - fixtureMise := filepath.Join(root, "tools/mise") - if err := os.Symlink(misePath, fixtureMise); err != nil { - t.Fatal(err) - } - if err := os.Symlink(composePath, filepath.Join(root, "tools/process-compose")); err != nil { - t.Fatal(err) - } - t.Setenv("PATH", filepath.Join(root, "tools")+string(os.PathListSeparator)+os.Getenv("PATH")) - t.Setenv("ONE_MISE_BINARY", fixtureMise) - buildWrite(t, root, "mise.toml", "[tools]\nnode=\"system\"\npnpm=\"system\"\n") - t.Setenv("MISE_TRUSTED_CONFIG_PATHS", root) - for _, key := range []string{"MISE_CONFIG_DIR", "MISE_CACHE_DIR", "MISE_DATA_DIR", "MISE_STATE_DIR"} { - t.Setenv(key, filepath.Join(root, key)) - } - - plan, e := miseconfig.Build(root, miseconfig.Options{}) - if e != nil { - t.Fatal(e) - } - if e = plan.Apply(context.Background()); e != nil { - t.Fatal(e) - } - return root -} - -func TestE2E_BuildOrdersProjectsAndKeepsStructuredOutputClean(t *testing.T) { - root := buildFixture(t, true) - t.Setenv("BUILD_VALUE", "from-shell") - stdout, stderr, code := runBinaryIn(t, filepath.Join(root, "apps/web"), "build", "--env", "prod", "-o", "json") - if code != 0 { - t.Fatalf("exit=%d stdout=%s stderr=%s", code, stdout, stderr) - } - var result buildmodule.Result - if err := json.Unmarshal([]byte(stdout), &result); err != nil { - t.Fatal(err, stdout) - } - if result.Schema != "one-cli/task-result/v1" || result.Status != "succeeded" || len(result.Tasks) != 3 { - t.Fatal(result) - } - for _, task := range result.Tasks { - if task.Status != "succeeded" { - t.Fatal(task) - } - } - if !strings.Contains(stderr, "web-env=from-shell") || !strings.Contains(stderr, "library-output") { - t.Fatal(stderr) - } - order, err := os.ReadFile(filepath.Join(root, "order")) - if err != nil || string(order) != "lib\nweb\n" { - t.Fatalf("%s %v", order, err) - } - installs, err := os.ReadFile(filepath.Join(root, "installs")) - if err != nil || string(installs) != "installed\n" { - t.Fatalf("%s %v", installs, err) - } -} - -func TestE2E_BuildFailureStopsRemainingTasksAndReturnsChildCode(t *testing.T) { - root := buildFixture(t, false) - buildWrite(t, root, "packages/lib/build.sh", "#!/bin/sh\necho build-failed\nexit 42\n") - stdout, stderr, code := runBinaryIn(t, root, "build", "-o", "json") - var result buildmodule.Result - if err := json.Unmarshal([]byte(stdout), &result); err != nil { - t.Fatal(err, stdout, stderr) - } - if code != 42 || result.ExitCode != 42 || result.Status != "failed" { - t.Fatalf("%d %+v %s", code, result, stderr) - } - if _, err := os.Stat(filepath.Join(root, "order")); !os.IsNotExist(err) { - t.Fatal("later project executed") - } -} - -func TestE2E_BuildPreviewAndProjectSelection(t *testing.T) { - root := buildFixture(t, true) - // These invalid env contents and a nonexistent mise prove preview never loads - // secrets or probes tools. The selected lib has no dev configuration. - buildWrite(t, root, "packages/lib/.env", "INVALID=\"unterminated") - t.Setenv("ONE_MISE_BINARY", filepath.Join(root, "missing-mise")) - stdout, stderr, code := runBinaryIn(t, root, "build", "-p", "packages/lib", "-p", "lib", "--dry-run", "-o", "yaml") - if code != 0 || stderr != "" { - t.Fatalf("%d %s %s", code, stdout, stderr) - } - var result map[string]any - if err := yaml.Unmarshal([]byte(stdout), &result); err != nil { - t.Fatal(err) - } - tasks := result["tasks"].([]any) - if result["schema"] != "one-cli/task-plan/v1" || len(tasks) != 1 { - t.Fatal(result) - } - for _, path := range []string{"installs", "order", "node_modules", "apps/web/mise.toml"} { - if _, err := os.Stat(filepath.Join(root, path)); !os.IsNotExist(err) { - t.Fatalf("dry-run wrote %s", path) - } - } - for _, args := range [][]string{{"build", "-p", "mobile", "--dry-run"}, {"build", "-p", "unknown", "--dry-run"}, {"build", "-p", "web", "-p", "lib", "--dry-run", "--", "argument"}, {"build", "-p", "web", "--env", "typo", "--dry-run"}} { - if _, _, code := runBinaryIn(t, root, args...); code == 0 { - t.Fatal(args) - } - } -} - -func TestE2E_GoLibraryTemplateHasBuildTask(t *testing.T) { - root := t.TempDir() - isolateHome(t, root) - ws := bootstrapWorkspace(t, root, "go-build") - _, stderr, code := runBinaryIn(t, ws, "add", "go-lib", "--name", "lib", "-y", "-o", "json") - if code != 0 { - t.Fatal(stderr) - } - stdout, stderr, code := runBinaryIn(t, ws, "build", "-p", "lib", "--dry-run", "-o", "json") - if code != 0 { - t.Fatalf("%d %s %s", code, stdout, stderr) - } - var plan buildmodule.Plan - if err := json.Unmarshal([]byte(stdout), &plan); err != nil { - t.Fatal(err) - } - if len(plan.Tasks) != 1 || plan.Tasks[0].Operation != "build" || plan.Tasks[0].Run != "task build --" { - t.Fatal(plan) - } - raw, err := os.ReadFile(filepath.Join(ws, "packages/lib/Taskfile.yml")) - if err != nil || !strings.Contains(string(raw), "go build {{.CLI_ARGS}} ./...") { - t.Fatalf("%s %v", raw, err) - } - raw, err = os.ReadFile(filepath.Join(ws, "mise.toml")) - if err != nil || !strings.Contains(string(raw), "[tasks.'lib:build']") { - t.Fatalf("%s %v", raw, err) - } -} - -func syncFixtureTasks(t *testing.T, root string) { - t.Helper() - plan, err := miseconfig.Build(root, miseconfig.Options{}) - if err != nil { - t.Fatal(err) - } - if err = plan.Apply(context.Background()); err != nil { - t.Fatal(err) - } -} diff --git a/packages/cli/tests/e2e/build_unix_test.go b/packages/cli/tests/e2e/build_unix_test.go deleted file mode 100644 index 7fd1cc2e..00000000 --- a/packages/cli/tests/e2e/build_unix_test.go +++ /dev/null @@ -1,88 +0,0 @@ -//go:build unix - -package cli_test - -import ( - "bytes" - "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "syscall" - "testing" - "time" - - buildmodule "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/tasks" -) - -func TestE2E_BuildSignalStopsProcessTree(t *testing.T) { - for _, sig := range []syscall.Signal{syscall.SIGINT, syscall.SIGTERM} { - t.Run(sig.String(), func(t *testing.T) { - root := buildFixture(t, false) - buildWrite(t, root, "packages/lib/build.sh", "#!/bin/sh\nsleep 60 &\necho $! > ../../grandchild.pid\nwait\n") - cmd := exec.Command(binaryPath(t), "build", "-o", "json") - cmd.Dir = root - cmd.Env = os.Environ() - var out, errOut bytes.Buffer - cmd.Stdout, cmd.Stderr = &out, &errOut - if err := cmd.Start(); err != nil { - t.Fatal(err) - } - defer cmd.Process.Kill() - done := make(chan error, 1) - go func() { done <- cmd.Wait() }() - deadline := time.Now().Add(10 * time.Second) - pid := 0 - for time.Now().Before(deadline) { - raw, err := os.ReadFile(filepath.Join(root, "grandchild.pid")) - if err == nil { - pid, _ = strconv.Atoi(strings.TrimSpace(string(raw))) - if pid > 0 { - break - } - } - time.Sleep(20 * time.Millisecond) - } - if pid == 0 { - t.Fatal("build process did not start") - } - defer syscall.Kill(pid, syscall.SIGKILL) - if err := cmd.Process.Signal(sig); err != nil { - t.Fatal(err) - } - select { - case err := <-done: - exit, ok := err.(*exec.ExitError) - want := 128 + int(sig) - if !ok || exit.ExitCode() != want { - t.Fatalf("exit %v; stdout=%s stderr=%s", err, out.String(), errOut.String()) - } - case <-time.After(10 * time.Second): - t.Fatal("build did not stop") - } - var result buildmodule.Result - if err := json.Unmarshal(out.Bytes(), &result); err != nil { - t.Fatal(err, out.String()) - } - if result.Status != "cancelled" { - t.Fatal(result.Tasks) - } - deadline = time.Now().Add(3 * time.Second) - for time.Now().Before(deadline) { - if syscall.Kill(pid, 0) == syscall.ESRCH { - return - } - // Linux may briefly retain a killed orphan as a zombie until init reaps it. - raw, _ := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) - if end := strings.LastIndex(string(raw), ") "); end >= 0 && strings.HasPrefix(string(raw)[end+2:], "Z ") { - return - } - time.Sleep(20 * time.Millisecond) - } - t.Fatalf("grandchild %d remains running", pid) - }) - } -} diff --git a/packages/cli/tests/e2e/create_removed_flags_test.go b/packages/cli/tests/e2e/create_removed_flags_test.go deleted file mode 100644 index a2867b61..00000000 --- a/packages/cli/tests/e2e/create_removed_flags_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package cli_test - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -// Removed automation flags must fail before creating even an empty workspace. -func TestE2E_CreateRejectsRemovedFlagsBeforeWriting(t *testing.T) { - for _, locale := range []string{"zh_CN.UTF-8", "en_US.UTF-8"} { - for _, flag := range []string{"--preset", "--project-names"} { - t.Run(locale+"/"+flag, func(t *testing.T) { - temp := t.TempDir() - isolateHome(t, filepath.Join(temp, "home")) - t.Setenv("LC_ALL", locale) - target := filepath.Join(temp, "demo") - stdout, stderr, code := runBinary(t, "create", target, flag, "retired", "--yes", "-o", "json") - if code == 0 || !strings.Contains(stderr+stdout, strings.TrimPrefix(flag, "--")) { - t.Fatalf("expected an error naming %s: %d %s %s", flag, code, stdout, stderr) - } - if _, err := os.Stat(target); !os.IsNotExist(err) { - t.Fatalf("removed flag changed destination: %v", err) - } - }) - } - } -} diff --git a/packages/cli/tests/e2e/dependencies_unix_test.go b/packages/cli/tests/e2e/dependencies_unix_test.go deleted file mode 100644 index 8a9bca9c..00000000 --- a/packages/cli/tests/e2e/dependencies_unix_test.go +++ /dev/null @@ -1,114 +0,0 @@ -//go:build unix - -package cli_test - -import ( - "os" - "os/exec" - "path/filepath" - "strings" - "syscall" - "testing" - "time" -) - -func TestE2E_DevSynchronizesPNPMLockfileAndReusesInstall(t *testing.T) { - root := devTerminalFixture(t, false) - buildWrite(t, root, "package.json", `{"packageManager":"pnpm@12.3.4"}`) - buildWrite(t, root, "pnpm-lock.yaml", "lockfileVersion: '9.0'\nimporters:\n .: {}\n") - buildWrite(t, root, "packages/lib/dev.sh", "echo DEV_STARTED\n") - buildWrite(t, root, "tools/pnpm", `#!/bin/sh -case "$1" in ---version) echo 12.3.4;; ---config.verify-deps-before-run=error) test -f deps-current;; -install) - printf '%s\n' "$*" >> installs - test "$2" = --no-frozen-lockfile || { echo ERR_PNPM_PACKAGE_MANAGER_NO_IMPORTER >&2; exit 1; } - mkdir -p node_modules/@build/lib - touch deps-current - ;; -*) exit 95;; -esac -`) - for i := 0; i < 2; i++ { - stdout, stderr, code := runBinaryIn(t, root, "dev", "-p", "lib", "--ui=stream", "-o", "text") - if code != 0 || !strings.Contains(stdout+stderr, "DEV_STARTED") { - t.Fatalf("run %d: exit=%d\n%s\n%s", i, code, stdout, stderr) - } - } - installs, err := os.ReadFile(filepath.Join(root, "installs")) - if err != nil || string(installs) != "install --no-frozen-lockfile\n" { - t.Fatalf("installs=%q, err=%v", installs, err) - } -} - -func TestE2E_DevPreparesDependenciesBeforeUpstreamBuildOnce(t *testing.T) { - root := devTerminalFixture(t, true) - buildWrite(t, root, "apps/web/dev.sh", "test -f ../../packages/lib/artifact || exit 94\necho DEV_STARTED\n") - buildWrite(t, root, "tools/pnpm", `#!/bin/sh -case "$1" in ---version) echo 12.3.4;; -install) - test "$2" = --no-frozen-lockfile || exit 92 - echo installed >> installs - touch pnpm-lock.yaml - mkdir -p node_modules - ;; -run) - test -f ../../pnpm-lock.yaml || exit 93 - shift 2 - exec sh build.sh "$@" - ;; -*) exit 95;; -esac -`) - stdout, stderr, code := runBinaryIn(t, root, "run", "dev", "-p", "web", "--ui=stream", "-o", "text") - if code != 0 || !strings.Contains(stdout+stderr, "DEV_STARTED") { - t.Fatalf("exit=%d\n%s\n%s", code, stdout, stderr) - } - installs, err := os.ReadFile(filepath.Join(root, "installs")) - if err != nil || string(installs) != "installed\n" { - t.Fatalf("installs=%q, err=%v", installs, err) - } - order, err := os.ReadFile(filepath.Join(root, "order")) - if err != nil || string(order) != "lib\n" { - t.Fatalf("only the upstream finite task should build: %q %v", order, err) - } -} - -func TestE2E_DevCancellationDuringDependencyPreparation(t *testing.T) { - root := devTerminalFixture(t, true) - buildWrite(t, root, "packages/lib/dev.sh", "touch DEV_SHOULD_NOT_START\n") - buildWrite(t, root, "tools/pnpm", `#!/bin/sh -case "$1" in ---version) echo 12.3.4;; -install) echo PREPARATION_READY >&2; sleep 60;; -*) exit 1;; -esac -`) - cmd := exec.Command(binaryPath(t), "dev", "-p", "lib", "-o", "json") - cmd.Dir, cmd.Env = root, os.Environ() - var logs lockedBuffer - cmd.Stdout, cmd.Stderr = &logs, &logs - if err := cmd.Start(); err != nil { - t.Fatal(err) - } - done := make(chan error, 1) - go func() { done <- cmd.Wait() }() - t.Cleanup(func() { _ = cmd.Process.Kill() }) - waitForTTYOutput(t, &logs, "PREPARATION_READY", 8*time.Second) - if err := cmd.Process.Signal(syscall.SIGTERM); err != nil { - t.Fatal(err) - } - select { - case err := <-done: - if exit, ok := err.(*exec.ExitError); !ok || exit.ExitCode() != 143 { - t.Fatalf("exit=%v logs=%s", err, logs.String()) - } - case <-time.After(8 * time.Second): - t.Fatal("preparation cancellation left a running child") - } - if _, err := os.Stat(filepath.Join(root, "packages/lib/DEV_SHOULD_NOT_START")); !os.IsNotExist(err) { - t.Fatal("development started after preparation cancellation") - } -} diff --git a/packages/cli/tests/e2e/e2e_helpers_test.go b/packages/cli/tests/e2e/e2e_helpers_test.go deleted file mode 100644 index 55958e02..00000000 --- a/packages/cli/tests/e2e/e2e_helpers_test.go +++ /dev/null @@ -1,353 +0,0 @@ -package cli_test - -// Shared helpers for the snapshot_e2e_*_test.go files in this package. -// They build the binary path relative to the test file's location, -// exec it, and compare JSON output against testdata/reference/ fixtures. -// -// Tests that depend on bin/one being present must call binaryPath; it -// t.Skip's if the binary isn't built yet (CI runs `mise run build` first). - -import ( - "bytes" - "encoding/json" - "os" - "os/exec" - "path/filepath" - "reflect" - "runtime" - "sort" - "strings" - "testing" - - "github.com/pelletier/go-toml/v2" -) - -// repoRoot returns the absolute path to the repository root, derived -// from this file's location at compile time. Survives `go test` being -// invoked from any working directory. -func repoRoot(t *testing.T) string { - t.Helper() - _, file, _, ok := runtime.Caller(0) - if !ok { - t.Fatalf("runtime.Caller failed") - } - // e2e_helpers_test.go lives at packages/cli/tests/e2e/, so two levels up. - return filepath.Join(filepath.Dir(file), "..", "..") -} - -func expectedBuildVersion(t *testing.T) string { - t.Helper() - if version := strings.TrimSpace(os.Getenv("RELEASE_VERSION")); version != "" { - return version - } - return "0.0.0-dev" -} - -// binaryPath returns the path to bin/one (bin/one.exe on Windows), skipping the test if the -// binary hasn't been built. Run `mise run build` first. -func binaryPath(t *testing.T) string { - t.Helper() - name := "one" - if runtime.GOOS == "windows" { - name += ".exe" - } - bin := filepath.Join(repoRoot(t), "bin", name) - if _, err := os.Stat(bin); err != nil { - t.Skipf("binary not built; run `mise run build` first (%v)", err) - } - return bin -} - -// runBinary execs bin/one with the given args from the current cwd. -func runBinary(t *testing.T, args ...string) (stdout, stderr string, exitCode int) { - return runBinaryIn(t, "", args...) -} - -// runBinaryIn execs bin/one in the given directory (empty = current). -// Inherits the test process env, so t.Setenv("HOME", ...) propagates. -func runBinaryIn(t *testing.T, dir string, args ...string) (stdout, stderr string, exitCode int) { - t.Helper() - bin := binaryPath(t) - // Legacy scaffold/contract fixtures stay offline. Skills installation has - // dedicated integration tests; those launch the binary explicitly. - if len(args) > 0 && (args[0] == "create" || args[0] == "add") { - args = append(append([]string(nil), args...), "--skip-skills") - } - cmd := exec.Command(bin, args...) - if dir != "" { - cmd.Dir = dir - } - cmd.Env = prependPath(os.Environ(), filepath.Dir(bin)) - var out, errBuf bytes.Buffer - cmd.Stdout = &out - cmd.Stderr = &errBuf - err := cmd.Run() - if exitErr, ok := err.(*exec.ExitError); ok { - exitCode = exitErr.ExitCode() - } else if err != nil { - t.Fatalf("exec error: %v", err) - } - return out.String(), errBuf.String(), exitCode -} - -func prependPath(env []string, dir string) []string { - out := append([]string{}, env...) - prefix := "PATH=" - for i, kv := range out { - key, path, found := strings.Cut(kv, "=") - isPath := key == "PATH" || (runtime.GOOS == "windows" && strings.EqualFold(key, "PATH")) - if found && isPath { - if path == "" { - out[i] = prefix + dir - } else { - out[i] = prefix + dir + string(os.PathListSeparator) + path - } - return out - } - } - return append(out, prefix+dir) -} - -// loadFixture reads a JSON fixture from testdata/reference/ and -// decodes it into a map for structural comparison. -func loadFixture(t *testing.T, name string) map[string]any { - t.Helper() - path := filepath.Join(repoRoot(t), "testdata", "reference", name) - raw, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read fixture %s: %v", path, err) - } - return mustParseJSON(t, string(raw)) -} - -// mustParseJSON decodes s into a map, failing the test on error. -func mustParseJSON(t *testing.T, s string) map[string]any { - t.Helper() - var m map[string]any - if err := json.Unmarshal([]byte(s), &m); err != nil { - t.Fatalf("decode json (input=%q): %v", s, err) - } - return m -} - -// firstJSONLine extracts the first complete { … } payload from a possibly -// multi-line buffer. The CLI emits the structured envelope first (now -// pretty-printed across multiple lines), then a plain one-line summary; -// only the JSON envelope participates in the contract. Uses json.Decoder -// to consume exactly one top-level value. -func firstJSONLine(s string) string { - idx := strings.IndexByte(s, '{') - if idx < 0 { - return "" - } - dec := json.NewDecoder(strings.NewReader(s[idx:])) - var raw json.RawMessage - if err := dec.Decode(&raw); err != nil { - return "" - } - return string(raw) -} - -// pretty indent-marshals v for use in test failure messages. -func pretty(v any) string { - b, _ := json.MarshalIndent(v, "", " ") - return string(b) -} - -// volatileKeys lists JSON keys whose values change per-run (timestamps, -// absolute paths, machine-specific lists). canonicalize scrubs them -// recursively before snapshot comparison so fixtures stay stable. -// -// Add new entries when a new test surfaces a new volatile key — keep -// the set tight: scrubbing too aggressively hides real regressions. -var volatileKeys = map[string]bool{ - "created_path": true, // create: absolute path under tempdir - "cwd": true, // error envelopes: absolute current directory - "package_json_path": true, // error envelopes: absolute path to package.json - "profile_path": true, // profile-add (legacy v3): absolute path to ~/.config/one/profiles.json - "config_path": true, // profile-add (v4): absolute path to ~/.config/one/config.json - "credentials_path": true, // profile-add (v4): absolute path to ~/.config/one/credentials.json - "display_path": true, // error envelopes: relative-or-absolute path, depends on cwd - "target_path": true, // create error: absolute target path - "workspace_path": true, - "workspace_root": true, - "root": true, // status: workspace.root is absolute - "updatedAt": true, // manifest: ISO timestamp - "updated_at": true, - "timestamp": true, // status envelopes - "detected_at": true, - "absolute_path": true, - "path": true, // status subproject entries - "node_version": true, // depends on local node install - "go_version": true, - "version_actual": true, - "git_commit": true, - "node_modules": true, - "home": true, - "message": true, // error envelopes' message embeds tempdir paths and is i18n-mutable; contract is `code`+`schema` - "files": true, // add-spec: list of absolute paths to generated spec files - "written_to": true, // env init: absolute path to one.manifest.toml - "url": true, // serve: per-run URL with random port - "port": true, // serve: kernel-assigned port when --port 0 -} - -// canonicalize returns a deep copy of m with all volatileKeys scrubbed -// to the sentinel string "". Maps and slices are walked -// recursively. The original is not mutated. -func canonicalize(v any) any { - switch x := v.(type) { - case map[string]any: - out := make(map[string]any, len(x)) - // Sort keys for deterministic walk; the resulting map's iteration - // order doesn't affect DeepEqual but stable order helps when - // debugging by printing intermediate state. - keys := make([]string, 0, len(x)) - for k := range x { - keys = append(keys, k) - } - sort.Strings(keys) - for _, k := range keys { - if volatileKeys[k] { - out[k] = "" - continue - } - out[k] = canonicalize(x[k]) - } - return out - case []any: - out := make([]any, len(x)) - for i, e := range x { - out[i] = canonicalize(e) - } - return out - default: - return v - } -} - -// assertSnapshot compares got (after canonicalize) against the fixture -// at testdata/reference/. On mismatch fails the test with a -// pretty-printed diff. -// -// When UPDATE_SNAPSHOTS=1 is set, writes the canonicalized got to the -// fixture file instead of comparing. Use this to bootstrap new fixtures -// or to update one after a deliberate output change — review the diff -// before committing. -func assertSnapshot(t *testing.T, name string, got map[string]any) { - t.Helper() - canon := canonicalize(got) - path := filepath.Join(repoRoot(t), "testdata", "reference", name) - if os.Getenv("UPDATE_SNAPSHOTS") == "1" { - b, err := json.MarshalIndent(canon, "", " ") - if err != nil { - t.Fatalf("marshal canon for %s: %v", path, err) - } - b = append(b, '\n') - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatalf("mkdir for fixture %s: %v", path, err) - } - if err := os.WriteFile(path, b, 0o644); err != nil { - t.Fatalf("write fixture %s: %v", path, err) - } - t.Logf("UPDATE_SNAPSHOTS=1: wrote %s", path) - return - } - raw, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read fixture %s: %v\n hint: run with UPDATE_SNAPSHOTS=1 to create", path, err) - } - var want any - if err := json.Unmarshal(raw, &want); err != nil { - t.Fatalf("parse fixture %s: %v", path, err) - } - if !reflect.DeepEqual(canon, want) { - t.Errorf("snapshot drift vs %s\n want: %s\n got: %s\n hint: re-run with UPDATE_SNAPSHOTS=1 if this change is intentional", - path, pretty(want), pretty(canon)) - } -} - -// isolateHome redirects HOME to the given dir for the duration of the -// test so profile writes cannot mutate the developer's actual config. -// -// Also clears XDG_CONFIG_HOME because profile.ConfigPath() honours -// it ahead of HOME (Linux convention). On Linux CI XDG_CONFIG_HOME is -// often set inherited from the runner shell, which would bleed -// config.json / credentials.json state across tests despite the -// per-test HOME. -func isolateHome(t *testing.T, dir string) { - t.Helper() - // The existing suite pins the native execution contract without installing - // project toolchains. Runtime integration tests explicitly clear this override. - t.Setenv("LC_ALL", "en_US.UTF-8") - t.Setenv("ONE_RUNTIME", "builtin") - t.Setenv("ONE_MISE_BINARY", "") - t.Setenv("XDG_CACHE_HOME", filepath.Join(dir, ".cache")) - t.Setenv("HOME", dir) - t.Setenv("XDG_CONFIG_HOME", "") - for _, key := range []string{"XDG_DATA_HOME", "XDG_STATE_HOME", "MISE_CONFIG_DIR", "MISE_DATA_DIR", "MISE_STATE_DIR", "MISE_CACHE_DIR"} { - t.Setenv(key, filepath.Join(dir, key)) - } - // Keep ambient task artifact caches separate from each fixture, even when - // a test explicitly enables native mise caching. - t.Setenv("MISE_TASK_CACHE_DIR", filepath.Join(dir, "mise-task-artifacts")) - for _, key := range []string{"CODEX_HOME", "CLAUDE_CONFIG_DIR", "VIBE_HOME"} { - t.Setenv(key, "") - } -} - -// fileExists is a tiny convenience wrapper used in tree-shape assertions. -func fileExists(t *testing.T, path string) bool { - t.Helper() - _, err := os.Stat(path) - return err == nil -} - -// readManifest parses one.manifest.toml at the given workspace root -// for spot assertions in tests. Returns the parsed map. -func readManifest(t *testing.T, workspaceRoot string) map[string]any { - t.Helper() - raw, err := os.ReadFile(filepath.Join(workspaceRoot, "one.manifest.toml")) - if err != nil { - t.Fatalf("read manifest: %v", err) - } - var manifest map[string]any - if err := toml.Unmarshal(raw, &manifest); err != nil { - t.Fatalf("parse manifest: %v", err) - } - return manifest -} - -// bootstrapWorkspace runs `one create / -y` to produce a -// fresh workspace under HOME-isolated tempdir, returning the workspace -// root. The caller is expected to have already called isolateHome(t, tmp). -// -// Used by add / status E2E tests that need a real workspace to operate on but -// don't themselves test create's output. Infisical remains unbound and -// the generated mise configuration is trusted in the isolated test home. -func bootstrapWorkspace(t *testing.T, tmp, name string) string { - t.Helper() - target := filepath.Join(tmp, name) - stdout, stderr, code := runBinary(t, "create", target, "-y", "-o", "json") - if code != 0 { - t.Fatalf("bootstrapWorkspace(%s) failed: exit %d\n stdout: %s\n stderr: %s", - name, code, stdout, stderr) - } - return target -} - -// setWorkspaceBindingFixture supplies a saved binding for tests that stop -// before authentication or remote access, such as input validation and prompts. -func setWorkspaceBindingFixture(t *testing.T, workspaceRoot string) { - t.Helper() - manifest := readManifest(t, workspaceRoot) - manifest["env"] = map[string]any{"infisical": map[string]any{ - "siteUrl": "http://127.0.0.1:1", "projectId": "test-project", "environments": []string{"dev"}, - }} - raw, err := toml.Marshal(manifest) - if err != nil { - t.Fatalf("encode bound workspace fixture: %v", err) - } - if err := os.WriteFile(filepath.Join(workspaceRoot, "one.manifest.toml"), raw, 0o644); err != nil { - t.Fatalf("write bound workspace fixture: %v", err) - } -} diff --git a/packages/cli/tests/e2e/electron_composite_test.go b/packages/cli/tests/e2e/electron_composite_test.go deleted file mode 100644 index 162aa2ef..00000000 --- a/packages/cli/tests/e2e/electron_composite_test.go +++ /dev/null @@ -1,109 +0,0 @@ -package cli_test - -import ( - "encoding/json" - "os" - "path/filepath" - "runtime" - "strings" - "testing" -) - -func TestE2E_ElectronCompositeNamingAndGroupPlans(t *testing.T) { - tmp := t.TempDir() - if runtime.GOOS != "windows" { - // Exercise the same directory-alias behavior as macOS's /var -> /private/var. - alias := filepath.Join(t.TempDir(), "linked-temp") - if err := os.Symlink(tmp, alias); err != nil { - t.Fatal(err) - } - tmp = alias - } - isolateHome(t, tmp) - root := bootstrapWorkspace(t, tmp, "workspace") - for _, name := range []string{"desktop", "electron"} { - stdout, stderr, code := runBinaryIn(t, root, "add", "electron-app", "--name", name, "-y", "-o", "json") - if code != 0 { - t.Fatalf("add: %d\n%s\n%s", code, stdout, stderr) - } - var result struct { - Name string `json:"subproject_name"` - Projects []struct { - Name string `json:"name"` - Path string `json:"target_path"` - } `json:"projects"` - } - if err := json.Unmarshal([]byte(stdout), &result); err != nil { - t.Fatal(err, stdout) - } - if result.Name != name || len(result.Projects) != 3 { - t.Fatal(stdout) - } - for i, location := range []struct{ directory, suffix string }{{"apps", "-renderer"}, {"services", "-main"}, {"packages", "-preload"}} { - member := result.Projects[i] - if member.Name != name+location.suffix { - t.Fatalf("member name=%q want %q", member.Name, name+location.suffix) - } - wantPath := filepath.Join(root, location.directory, member.Name) - wantDirectory, err := os.Stat(wantPath) - if err != nil { - t.Fatal(err) - } - gotDirectory, err := os.Stat(member.Path) - if err != nil { - t.Fatal(err) - } - if !os.SameFile(wantDirectory, gotDirectory) { - t.Fatalf("member path=%q want %q", member.Path, wantPath) - } - if _, err := os.Stat(filepath.Join(member.Path, "package.json")); err != nil { - t.Fatal(err) - } - } - if _, err := os.Stat(filepath.Join(root, "apps", name)); !os.IsNotExist(err) { - t.Fatal("nested wrapper still exists") - } - } - for _, op := range []string{"dev", "build", "test", "pack"} { - stdout, stderr, code := runBinaryIn(t, root, "run", "desktop:"+op, "--dry-run", "-o", "json") - if code != 0 { - t.Fatalf("plan %s: %d\n%s\n%s", op, code, stdout, stderr) - } - var plan struct { - Tasks []struct { - Name string `json:"name"` - Project string `json:"project"` - } `json:"tasks"` - } - if err := json.Unmarshal([]byte(stdout), &plan); err != nil { - t.Fatal(err, stdout) - } - found := map[string]bool{} - for _, task := range plan.Tasks { - if strings.HasPrefix(task.Project, "electron-") { - t.Fatalf("group crossed into second app: %s", stdout) - } - found[task.Name] = true - } - for _, member := range []string{"desktop-renderer", "desktop-main", "desktop-preload"} { - task := "//:" + member + ":build" - if op == "dev" { - task = "//:" + member + ":dev" - } - if op == "test" && member == "desktop-main" { - task = "//:" + member + ":test" - } - if !found[task] { - t.Fatalf("missing %s in %s", task, stdout) - } - } - } - stdout, stderr, code := runBinaryIn(t, root, "init", "mise", "-o", "json") - if code != 0 { - t.Fatalf("refresh: %d\n%s\n%s", code, stdout, stderr) - } - stdout, stderr, code = runBinaryIn(t, root, "run", "desktop:pack", "--dry-run", "-o", "json") - if code != 0 { - t.Fatalf("refreshed group: %d\n%s\n%s", code, stdout, stderr) - } -} diff --git a/packages/cli/tests/e2e/empty_project_test.go b/packages/cli/tests/e2e/empty_project_test.go deleted file mode 100644 index 10b8a74e..00000000 --- a/packages/cli/tests/e2e/empty_project_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package cli_test - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestE2E_AddEmptyProjects(t *testing.T) { - for _, locale := range []string{"en_US.UTF-8", "zh_CN.UTF-8"} { - t.Run(locale, func(t *testing.T) { - tmp := t.TempDir() - isolateHome(t, tmp) - t.Setenv("LC_ALL", locale) - root := bootstrapWorkspace(t, tmp, "empty-projects") - initialMise, err := os.ReadFile(filepath.Join(root, "mise.toml")) - if err != nil { - t.Fatal(err) - } - for _, tc := range []struct{ id, name, directory string }{ - {"empty-app", "web", "apps"}, - {"empty-service", "api", "services"}, - {"empty-library", "shared", "packages"}, - } { - stdout, stderr, code := runBinaryIn(t, root, "add", tc.id, "--name", tc.name, "--yes", "-o", "json") - if code != 0 { - t.Fatalf("add %s: exit %d\n%s\n%s", tc.id, code, stdout, stderr) - } - result := mustParseJSON(t, stdout) - if result["template_id"] != tc.id || result["toolchain"] != "none" || result["package_manager"] != nil { - t.Fatalf("unexpected result: %v", result) - } - dir := filepath.Join(root, tc.directory, tc.name) - entries, err := os.ReadDir(dir) - if err != nil || len(entries) != 1 || entries[0].Name() != ".gitkeep" { - t.Fatalf("empty project files = %v, err = %v", entries, err) - } - // Reusing the name in another category must neither register it nor - // leave the newly rendered directory behind. - _, _, code = runBinaryIn(t, root, "add", "empty-library", "--name", tc.name, "--yes", "-o", "json") - if code == 0 { - t.Fatal("duplicate project name was accepted") - } - if tc.directory != "packages" { - if _, err := os.Stat(filepath.Join(root, "packages", tc.name)); !os.IsNotExist(err) { - t.Fatalf("duplicate left a directory: %v", err) - } - } - } - manifest := readManifest(t, root) - projects := manifest["projects"].(map[string]any) - if len(projects) != 3 { - t.Fatalf("projects = %v", projects) - } - for _, raw := range projects { - p := raw.(map[string]any) - if p["toolchain"] != "none" || p["dev"] != nil || p["packageManager"] != nil { - t.Fatalf("empty project acquired runtime configuration: %v", p) - } - } - for _, file := range []string{"package.json", "pnpm-workspace.yaml", "go.work"} { - if _, err := os.Stat(filepath.Join(root, file)); !os.IsNotExist(err) { - t.Fatalf("empty projects generated %s: %v", file, err) - } - } - mise, err := os.ReadFile(filepath.Join(root, "mise.toml")) - if err != nil { - t.Fatal(err) - } - if string(mise) != string(initialMise) { - t.Fatalf("empty projects changed workspace tools or tasks:\n%s", mise) - } - stdout, stderr, code := runBinaryIn(t, root, "add", "empty-app", "--name", "custom", "--yes", "-o", "text") - hint := "Add your code" - if locale == "zh_CN.UTF-8" { - hint = "添加代码" - } - if code != 0 || !strings.Contains(stdout, hint) || strings.Contains(stdout, "one dev -p") { - t.Fatalf("empty project guidance: exit %d\n%s\n%s", code, stdout, stderr) - } - }) - } -} diff --git a/packages/cli/tests/e2e/install_ps1_test.go b/packages/cli/tests/e2e/install_ps1_test.go deleted file mode 100644 index 072c7c97..00000000 --- a/packages/cli/tests/e2e/install_ps1_test.go +++ /dev/null @@ -1,139 +0,0 @@ -package cli_test - -import ( - "archive/zip" - "bytes" - "crypto/sha256" - "fmt" - "net/http" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "testing" -) - -func TestInstallPS1_InstallsVerifiedLocalArchive(t *testing.T) { - if runtime.GOOS != "windows" { - t.Skip("install.ps1 executes only on Windows") - } - powerShell, err := exec.LookPath("pwsh") - if err != nil { - powerShell, err = exec.LookPath("powershell.exe") - } - if err != nil { - t.Skipf("PowerShell not on PATH: %v", err) - } - - binary, err := os.ReadFile(binaryPath(t)) - if err != nil { - t.Fatal(err) - } - var archive bytes.Buffer - zipWriter := zip.NewWriter(&archive) - entry, err := zipWriter.Create("one.exe") - if err != nil { - t.Fatal(err) - } - if _, err := entry.Write(binary); err != nil { - t.Fatal(err) - } - if err := zipWriter.Close(); err != nil { - t.Fatal(err) - } - sum := sha256.Sum256(archive.Bytes()) - checksums := fmt.Sprintf("%x one-cli_windows_amd64.zip\n", sum) - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/v9.9.9/one-cli_windows_amd64.zip": - w.WriteHeader(http.StatusOK) - _, _ = w.Write(archive.Bytes()) - case "/v9.9.9/checksums.txt": - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(checksums)) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - - installDir := filepath.Join(t.TempDir(), "One CLI", "bin") - cmd := exec.Command(powerShell, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", installPowerShellPath(t)) - cmd.Env = append(os.Environ(), - "ONE_VERSION=v9.9.9", - "ONE_RELEASE_BASE_URL="+server.URL, - "ONE_INSTALL_DIR="+installDir, - "ONE_FORCE=1", - "ONE_SKIP_VERIFY=0", - "ONE_NO_PATH_UPDATE=1", - ) - output, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("install.ps1 local install failed:\n%s\n%v", output, err) - } - installed, err := os.ReadFile(filepath.Join(installDir, "one.exe")) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(installed, binary) { - t.Fatal("installed one.exe differs from the verified archive payload") - } -} - -func installPowerShellPath(t *testing.T) string { - t.Helper() - path := filepath.Join(repoRoot(t), "..", "..", "apps", "docs", "public", "install.ps1") - if _, err := os.Stat(path); err != nil { - t.Skipf("install.ps1 missing (%v) — skip if running outside the main repo", err) - } - return path -} - -func TestInstallPS1_SyntaxValid(t *testing.T) { - executable, err := exec.LookPath("pwsh") - if err != nil && runtime.GOOS == "windows" { - executable, err = exec.LookPath("powershell.exe") - } - if err != nil { - t.Skipf("PowerShell not on PATH: %v", err) - } - - script := installPowerShellPath(t) - parseOnly := "$tokens = $null; $errors = $null; " + - "[System.Management.Automation.Language.Parser]::ParseFile($env:ONE_INSTALLER_TEST_PATH, [ref]$tokens, [ref]$errors) | Out-Null; " + - "if ($errors.Count -gt 0) { $errors | ForEach-Object { Write-Error $_.Message }; exit 1 }" - cmd := exec.Command(executable, "-NoProfile", "-NonInteractive", "-Command", parseOnly) - cmd.Env = append(os.Environ(), "ONE_INSTALLER_TEST_PATH="+script) - output, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("install.ps1 syntax check failed:\n%s\n%v", output, err) - } -} - -func TestInstallPS1_RequiredSentinels(t *testing.T) { - body, err := os.ReadFile(installPowerShellPath(t)) - if err != nil { - t.Fatal(err) - } - content := string(body) - for _, want := range []string{ - "one-cli_windows_amd64.zip", - "checksums.txt", - "Get-FileHash", - "Expand-Archive", - "ONE_VERSION", - "ONE_INSTALL_DIR", - "ONE_FORCE", - "ONE_SKIP_VERIFY", - "ONE_NO_PATH_UPDATE", - "SetEnvironmentVariable(\"Path\"", - "[IO.File]::Replace", - } { - if !strings.Contains(content, want) { - t.Errorf("install.ps1 is missing required sentinel %q", want) - } - } -} diff --git a/packages/cli/tests/e2e/install_sh_test.go b/packages/cli/tests/e2e/install_sh_test.go deleted file mode 100644 index f8eb6b49..00000000 --- a/packages/cli/tests/e2e/install_sh_test.go +++ /dev/null @@ -1,158 +0,0 @@ -package cli_test - -// Static checks for the curl-based installer at apps/docs/public/install.sh. -// The script is shipped to users via the public docs site, so a regression -// here means broken installs. We can't run the script end-to-end without -// hitting GitHub Releases, but we can -// guard against the cheap-but-real failure modes: -// -// 1. Bash syntax error introduced by an edit (bash -n parse-only) -// 2. A required sentinel removed accidentally (strict-mode flags, -// SHA256 verification, OS/ARCH matrix, configurable env vars) -// -// Skipped when /bin/bash is not present (e.g. minimal Windows runners). - -import ( - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "testing" -) - -func installScriptPath(t *testing.T) string { - t.Helper() - path := filepath.Join(repoRoot(t), "..", "..", "apps", "docs", "public", "install.sh") - if _, err := os.Stat(path); err != nil { - t.Skipf("install.sh missing (%v) — skip if running outside the main repo", err) - } - return path -} - -func TestInstallSh_BashSyntaxValid(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("install.sh syntax is covered on Unix; Windows uses install.ps1") - } - bashPath, err := exec.LookPath("bash") - if err != nil { - t.Skipf("bash not on PATH: %v", err) - } - script := installScriptPath(t) - - cmd := exec.Command(bashPath, "-n", script) - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("bash -n failed (regression — install.sh has syntax error):\n output: %s\n err: %v", - string(out), err) - } -} - -func TestInstallSh_RequiredSentinels(t *testing.T) { - script := installScriptPath(t) - body, err := os.ReadFile(script) - if err != nil { - t.Fatal(err) - } - content := string(body) - - // Categories of required substrings. Each entry is a comment + - // one-or-more strings any of which must appear (helps when the - // script is refactored without changing the contract). - type sentinel struct { - why string - want []string // any-of - } - required := []sentinel{ - { - why: "strict-mode opt-in protects against silent failures during install", - want: []string{"set -euo pipefail"}, - }, - { - why: "SHA256 verification is the security-critical step (no signed releases yet)", - want: []string{"sha256", "shasum", "SHA256"}, - }, - { - why: "macOS support — drop-out here means Apple users get a misleading error", - want: []string{"Darwin", "darwin"}, - }, - { - why: "Linux support", - want: []string{"Linux", "linux"}, - }, - { - why: "ARM64 support — Apple Silicon and Linux ARM", - want: []string{"arm64", "aarch64"}, - }, - { - why: "AMD64 support", - want: []string{"amd64", "x86_64"}, - }, - { - why: "ONE_VERSION env var lets users pin a specific version (used in CI)", - want: []string{"ONE_VERSION"}, - }, - { - why: "ONE_INSTALL_DIR env var matches `task install` convention", - want: []string{"ONE_INSTALL_DIR"}, - }, - { - why: "wrap entire flow in a function so a truncated curl can't run a half-script", - want: []string{"main()", "main ", "function main"}, - }, - { - why: "ONE_FORCE env var still wired for downgrade / force-reinstall paths", - want: []string{"ONE_FORCE"}, - }, - { - why: "version_compare helper drives the upgrade/skip/downgrade decision", - want: []string{"version_compare"}, - }, - { - why: "current_version helper reads the installed binary's --version", - want: []string{"current_version"}, - }, - { - why: "downgrade path must surface a clear, actionable error", - want: []string{"downgrade blocked"}, - }, - } - - for _, s := range required { - matched := false - for _, candidate := range s.want { - if strings.Contains(content, candidate) { - matched = true - break - } - } - if !matched { - t.Errorf("install.sh is missing %q sentinel — none of %v found", s.why, s.want) - } - } -} - -func TestInstallSh_NoTrailingMainCallBeforeWrapping(t *testing.T) { - // Belt-and-braces: the wrap-in-main pattern only protects against - // truncated curl downloads if `main "$@"` is the very last - // non-comment, non-blank line. If somebody added something after - // it (e.g. a stray cleanup line), the protection breaks silently. - script := installScriptPath(t) - body, err := os.ReadFile(script) - if err != nil { - t.Fatal(err) - } - lines := strings.Split(string(body), "\n") - - lastCode := "" - for _, line := range lines { - trim := strings.TrimSpace(line) - if trim == "" || strings.HasPrefix(trim, "#") { - continue - } - lastCode = trim - } - if !strings.HasPrefix(lastCode, "main ") && lastCode != "main" { - t.Errorf("last code line should invoke main() so a truncated curl is safe; got %q", lastCode) - } -} diff --git a/packages/cli/tests/e2e/locale_terminal_unix_test.go b/packages/cli/tests/e2e/locale_terminal_unix_test.go deleted file mode 100644 index 3a49cb93..00000000 --- a/packages/cli/tests/e2e/locale_terminal_unix_test.go +++ /dev/null @@ -1,35 +0,0 @@ -//go:build unix - -package cli_test - -import ( - "strings" - "testing" - "time" - "unicode" - - "github.com/charmbracelet/x/ansi" -) - -func TestE2E_AddPromptAndBuiltInTemplatesFollowLocale(t *testing.T) { - for _, tc := range []struct{ locale, title, template string }{ - {"en_US.UTF-8", "What would you like to add?", "React single-page application"}, - {"zh_CN.UTF-8", "想添加什么?", "React 单页应用"}, - } { - t.Run(tc.locale, func(t *testing.T) { - tmp := t.TempDir() - isolateHome(t, tmp) - root := bootstrapWorkspace(t, tmp, "demo") - t.Setenv("LC_ALL", tc.locale) - terminal := startTaskTerminal(t, root, "add") - waitForTTYOutput(t, terminal.out, tc.title, 8*time.Second) - _, _ = terminal.pty.Write([]byte("\r")) - waitForTTYOutput(t, terminal.out, tc.template, 8*time.Second) - _, _ = terminal.pty.Write([]byte{3}) - terminal.wait(t, 0) - if tc.locale == "en_US.UTF-8" && strings.ContainsFunc(ansi.Strip(terminal.out.String()), func(r rune) bool { return unicode.Is(unicode.Han, r) }) { - t.Fatalf("English prompt contains Chinese: %s", terminal.out.String()) - } - }) - } -} diff --git a/packages/cli/tests/e2e/locale_test.go b/packages/cli/tests/e2e/locale_test.go deleted file mode 100644 index 4b27f0ae..00000000 --- a/packages/cli/tests/e2e/locale_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package cli_test - -import ( - "strings" - "testing" - "unicode" - - "github.com/spf13/pflag" - cli "github.com/torchstellar-team/one-cli/packages/cli/internal/bootstrap/cli" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" -) - -func TestAllHelpRefreshesBetweenLocales(t *testing.T) { - root := cli.RootCmd() - t.Cleanup(func() { _ = i18n.Init(i18n.DefaultLocale); i18n.RefreshTree(root) }) - for _, locale := range []string{"en-US", "zh-CN", "en-US"} { - _ = i18n.Init(locale) - i18n.RefreshTree(root) - for _, walk := range walkTree(root) { - cmd := walk.cmd - for annotation, value := range map[string]string{i18n.AnnotationShort: cmd.Short, i18n.AnnotationLong: cmd.Long} { - if value == "" { - continue - } - key := cmd.Annotations[annotation] - if key == "" || i18n.T(key) != value { - t.Errorf("%s %s not registered/refreshed: %q", cmd.CommandPath(), annotation, value) - } - } - checkFlag := func(flag *pflag.Flag) { - if flag.Hidden || flag.Name == "help" { - return - } - keys := flag.Annotations[i18n.AnnotationFlag] - if len(keys) == 0 || flag.Usage != i18n.T(keys[0]) { - t.Errorf("%s --%s not registered/refreshed", cmd.CommandPath(), flag.Name) - } - } - cmd.LocalNonPersistentFlags().VisitAll(checkFlag) - cmd.PersistentFlags().VisitAll(checkFlag) - help := renderHelp(t, cmd) - if locale == "en-US" && strings.ContainsFunc(help, func(r rune) bool { return unicode.Is(unicode.Han, r) }) { - t.Errorf("English help contains Chinese: %s\n%s", cmd.CommandPath(), help) - } - } - } -} - -func TestE2E_LocalePreferenceAndImmediateConfirmation(t *testing.T) { - root := t.TempDir() - isolateHome(t, root) - t.Setenv("LC_ALL", "en_US.UTF-8") - zh, stderr, code := runBinaryIn(t, root, "locale", "zh-CN", "-o", "text") - if code != 0 || !strings.Contains(zh, "显示语言已设置为 zh-CN") { - t.Fatalf("switch to Chinese: %s %s", zh, stderr) - } - for _, args := range [][]string{{"run", "--help"}, {"login", "--help"}, {"templates", "-o", "text"}} { - out, errText, code := runBinaryIn(t, root, args...) - if code != 0 || !strings.ContainsFunc(out, func(r rune) bool { return unicode.Is(unicode.Han, r) }) { - t.Fatalf("Chinese %v: %s %s", args, out, errText) - } - } - _, stderr, code = runBinaryIn(t, root, "env", "unset", "-o", "text") - if code == 0 || !strings.Contains(stderr, "参数") || !strings.Contains(stderr, "one env unset") { - t.Fatalf("Chinese argument error: %s", stderr) - } - _, stderr, code = runBinaryIn(t, root, "serve", "--port=invalid", "-o", "text") - if code == 0 || !strings.Contains(stderr, "无效") || !strings.Contains(stderr, "invalid") { - t.Fatalf("Chinese flag error: %s", stderr) - } - out, stderr, code := runBinaryIn(t, root, "locale", "en-US", "-o", "text") - if code != 0 || !strings.Contains(out, "en-US") || strings.ContainsFunc(out, func(r rune) bool { return unicode.Is(unicode.Han, r) }) { - t.Fatalf("switch to English: %s %s", out, stderr) - } - for _, args := range [][]string{{"run", "--help"}, {"templates", "-o", "text"}} { - out, errText, code := runBinaryIn(t, root, args...) - if code != 0 || strings.ContainsFunc(out, func(r rune) bool { return unicode.Is(unicode.Han, r) }) { - t.Fatalf("English %v: %s %s", args, out, errText) - } - } - t.Setenv("LC_ALL", "zh_CN.UTF-8") - out, stderr, code = runBinaryIn(t, root, "locale", "auto", "-o", "json") - if code != 0 { - t.Fatalf("auto: %s %s", out, stderr) - } - result := mustParseJSON(t, out) - if result["schema"] != "one-cli/locale/v1" || result["stored_locale"] != "auto" || result["resolved"] != "zh-CN" { - t.Fatalf("locale protocol changed: %v", result) - } -} diff --git a/packages/cli/tests/e2e/mise_runtime_test.go b/packages/cli/tests/e2e/mise_runtime_test.go deleted file mode 100644 index 2bdd055b..00000000 --- a/packages/cli/tests/e2e/mise_runtime_test.go +++ /dev/null @@ -1,449 +0,0 @@ -package cli_test - -import ( - "crypto/sha256" - "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/adapters/runtime/mise/miserelease" -) - -func runtimeFixture(t *testing.T) string { - t.Helper() - root := t.TempDir() - isolateHome(t, root) - t.Setenv("XDG_DATA_HOME", filepath.Join(root, ".local", "share")) - t.Setenv("XDG_STATE_HOME", filepath.Join(root, ".local", "state")) - t.Setenv("ONE_RUNTIME", "") - files := map[string]string{ - "one.manifest.toml": `version = 2 - -[workspace] -id = "runtime-test" -name = "runtime-test" - -[projects."web"] -path = "apps/web" -toolchain = "node" - -[projects."api"] -path = "services/api" -toolchain = "go" -`, - "mise.toml": "[env]\nONE_MISE_TEST_VALUE = 'root'\nONE_MISE_PARENT = 'root-only'\n", - "apps/web/mise.toml": "[env]\nONE_MISE_TEST_VALUE = 'project'\nONE_MISE_ONLY = 'from-mise'\n", - "apps/web/.env": "ONE_MISE_TEST_VALUE=web-secret\nWEB_ONLY=web-only\n", - "services/api/.env": "ONE_MISE_TEST_VALUE=api-secret\nAPI_ONLY=api-only\n", - "apps/web/package.json": `{"scripts":{"dev":"node dev.cjs"}}`, - } - for rel, content := range files { - path := filepath.Join(root, rel) - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { - t.Fatal(err) - } - } - return root -} - -func installFakeMise(t *testing.T) { - t.Helper() - if runtime.GOOS == "windows" { - t.Skip("POSIX fake mise; real integration can run on Windows") - } - dir := t.TempDir() - script := `#!/bin/sh -if [ "$1" = --version ]; then echo '2026.9.7 linux-x64'; exit 0; fi -if [ "$1" = env ]; then - cat <<'ENV' -${Env:ONE_MISE_TEST_VALUE}='from-mise' -${Env:ONE_MISE_ONLY}='from-mise' -ENV - exit 0 -fi -[ "$1" = exec ] && [ "$2" = -- ] || exit 91 -shift 2 -export ONE_MISE_TEST_VALUE=from-mise -export ONE_MISE_ONLY=from-mise -exec "$@" -` - - if err := os.WriteFile(filepath.Join(dir, "mise"), []byte(script), 0o755); err != nil { - t.Fatal(err) - } - t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - t.Setenv("ONE_MISE_BINARY", filepath.Join(dir, "mise")) -} - -func TestE2E_MiseRuntimeOriginalRunSyntaxAndExitCode(t *testing.T) { - root := runtimeFixture(t) - installFakeMise(t) - args := append([]string{"exec", "web", "--"}, environmentEchoCommand("ONE_MISE_TEST_VALUE")...) - out, errOut, code := runBinaryIn(t, root, args...) - if code != 0 || strings.TrimSpace(out) != "from-mise" { - t.Fatalf("code=%d stdout=%q stderr=%q", code, out, errOut) - } - args = append([]string{"exec", "web", "--"}, exitWithCodeCommand(42)...) - _, _, code = runBinaryIn(t, root, args...) - if code != 42 { - t.Fatalf("exit code = %d, want 42", code) - } - // Literal argv must survive mise and the terminal One execution leaf. - args = []string{"exec", "web", "--", "sh", "-c", `printf '<%s>\n' "$@"`, "probe", "a b", "", `$(not-a-command)`, "中文", `a'b"c`} - out, errOut, code = runBinaryIn(t, root, args...) - if code != 0 || out != "\n<>\n<$(not-a-command)>\n<中文>\n\n" { - t.Fatalf("argv: code=%d stdout=%q stderr=%q", code, out, errOut) - } -} - -func TestE2E_MisePassthroughKeepsArgumentsExitAndDoesNotLoadOneSecrets(t *testing.T) { - root := runtimeFixture(t) - installFakeMise(t) - out, stderr, code := runBinaryIn(t, root, "mise", "--version") - if code != 0 || !strings.HasPrefix(out, "2026.9.7") { - t.Fatalf("version: %d %q %s", code, out, stderr) - } - out, stderr, code = runBinaryIn(t, filepath.Join(root, "apps/web"), "mise", "exec", "--", "sh", "-c", `printf '<%s>\n' "${WEB_ONLY:-not-loaded}" "$@"`, "probe", "a b", "", `$(literal)`) - if code != 0 || out != "\n\n<>\n<$(literal)>\n" { - t.Fatalf("passthrough: %d %q %s", code, out, stderr) - } - _, _, code = runBinaryIn(t, root, "mise", "exec", "--", "sh", "-c", "exit 37") - if code != 37 { - t.Fatalf("passthrough exit = %d", code) - } -} - -func TestE2E_MiseTrustThroughOneBeforeRunningProject(t *testing.T) { - mise := os.Getenv("ONE_TEST_MISE_BINARY") - if mise == "" { - t.Skip("set ONE_TEST_MISE_BINARY for real trust integration") - } - root := runtimeFixture(t) - useRealMise(t, root, mise) - t.Setenv("MISE_TRUSTED_CONFIG_PATHS", "") - t.Setenv("MISE_PARANOID", "1") - // Static env values need no trust; command templates deliberately do. - if err := os.WriteFile(filepath.Join(root, "apps/web/mise.toml"), []byte(`[env] -ONE_MISE_TEST_VALUE = '{{ exec(command="echo from-mise") }}' -`), 0o644); err != nil { - t.Fatal(err) - } - args := append([]string{"exec", "web", "--"}, environmentEchoCommand("ONE_MISE_TEST_VALUE")...) - if _, _, code := runBinaryIn(t, root, args...); code == 0 { - t.Fatal("untrusted configuration ran") - } - for _, rel := range []string{"mise.toml", "apps/web/mise.toml"} { - out, stderr, code := runBinaryIn(t, root, "mise", "trust", filepath.Join(root, rel)) - if code != 0 { - t.Fatalf("trust: %d %q %s", code, out, stderr) - } - } - out, stderr, code := runBinaryIn(t, root, args...) - if code != 0 || strings.TrimSpace(out) != "from-mise" { - t.Fatalf("trusted run: %d %q %s", code, out, stderr) - } -} - -func TestE2E_MiseMissingReportsErrorAndLegacyStillRuns(t *testing.T) { - root := runtimeFixture(t) - t.Setenv("PATH", t.TempDir()) - t.Setenv("ONE_MISE_BINARY", filepath.Join(t.TempDir(), "missing-mise")) - out, stderr, code := runBinaryIn(t, root, "exec", "web", "--", "unavailable-command") - if code == 0 || !strings.Contains(out+stderr, "MISE_NOT_FOUND") { - t.Fatalf("missing mise: %d %s %s", code, out, stderr) - } - // Removing only the One-generated root configuration models an old workspace. - if err := os.Remove(filepath.Join(root, "mise.toml")); err != nil { - t.Fatal(err) - } - out, stderr, code = runBinaryIn(t, root, "exec", "web", "--dry-run", "--", "unavailable-command") - if code != 0 || !strings.Contains(out, `"runtime": "builtin"`) { - t.Fatalf("legacy runtime: %d %s %s", code, out, stderr) - } -} - -func TestE2E_MiseDryRunDoesNotRunHooksOrNeedMise(t *testing.T) { - root := runtimeFixture(t) - probe := filepath.Join(t.TempDir(), "mise") - if runtime.GOOS != "windows" { - if err := os.WriteFile(probe, []byte("#!/bin/sh\ntouch \""+filepath.Join(root, "mise-was-called")+"\"\nexit 99\n"), 0o755); err != nil { - t.Fatal(err) - } - t.Setenv("PATH", filepath.Dir(probe)+string(os.PathListSeparator)+os.Getenv("PATH")) - t.Setenv("ONE_MISE_BINARY", probe) - } - out, errOut, code := runBinaryIn(t, root, "exec", "web", "--dry-run", "--", "does-not-exist") - if code != 0 { - t.Fatalf("dry run: %d %s", code, errOut) - } - var plan struct { - Runtime string `json:"runtime"` - DryRun bool `json:"dry_run"` - } - if err := json.Unmarshal([]byte(out), &plan); err != nil { - t.Fatal(err) - } - if plan.Runtime != "mise" || !plan.DryRun || strings.Contains(out, "web-secret") { - t.Fatalf("bad plan: %s", out) - } - for _, rel := range []string{".cache/one/runtimes/mise", ".local/share/one/runtimes/mise"} { - if _, err := os.Stat(filepath.Join(root, rel)); !os.IsNotExist(err) { - t.Fatal("dry-run prepared the runtime") - } - } - if _, err := os.Stat(filepath.Join(root, "mise-was-called")); !os.IsNotExist(err) { - t.Fatal("preview invoked mise") - } -} - -func TestE2E_MiseRealConfigurationLayering(t *testing.T) { - mise := os.Getenv("ONE_TEST_MISE_BINARY") - if mise == "" { - t.Skip("set ONE_TEST_MISE_BINARY to run the real mise integration") - } - root := runtimeFixture(t) - useRealMise(t, root, mise) - for _, pair := range []struct{ project, key, want string }{ - {"web", "ONE_MISE_TEST_VALUE", "project"}, {"web", "ONE_MISE_ONLY", "from-mise"}, - {"web", "ONE_MISE_PARENT", "root-only"}, {"api", "ONE_MISE_TEST_VALUE", "root"}, - } { - args := append([]string{"exec", pair.project, "--"}, environmentEchoCommand(pair.key)...) - out, stderr, code := runBinaryIn(t, root, args...) - if code != 0 || strings.TrimSpace(out) != pair.want { - t.Fatalf("%+v: code=%d stdout=%q stderr=%q", pair, code, out, stderr) - } - } - probe := []string{"sh", "-c", `printf '%s' "${WEB_ONLY:-isolated}"`} - if runtime.GOOS == "windows" { - probe = []string{"cmd.exe", "/d", "/s", "/c", "if defined WEB_ONLY (echo leaked) else (echo isolated)"} - } - out, stderr, code := runBinaryIn(t, root, append([]string{"exec", "api", "--"}, probe...)...) - if code != 0 || strings.TrimSpace(out) != "isolated" { - t.Fatalf("project leak: %d %q %s", code, out, stderr) - } -} - -func useRealMise(t *testing.T, root, mise string) { - t.Helper() - t.Setenv("PATH", filepath.Dir(mise)+string(os.PathListSeparator)+os.Getenv("PATH")) - t.Setenv("ONE_MISE_BINARY", mise) - isolateMiseState(t, root) -} - -func isolateMiseState(t *testing.T, root string) { - t.Helper() - for _, key := range []string{"MISE_DATA_DIR", "MISE_STATE_DIR", "MISE_CACHE_DIR", "MISE_CONFIG_DIR"} { - t.Setenv(key, filepath.Join(root, key)) - } - t.Setenv("MISE_TRUSTED_CONFIG_PATHS", root) - // Never fetch tools during this environment/dispatch contract test. - t.Setenv("MISE_AUTO_INSTALL", "false") -} - -func disableMiseNetwork(t *testing.T) { - t.Helper() - for _, key := range []string{"HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"} { - t.Setenv(key, "http://127.0.0.1:1") - } - t.Setenv("NO_PROXY", "") - t.Setenv("no_proxy", "") -} - -func TestE2E_MiseSystemDiscoveryThenDeletionReportsDownloadFailure(t *testing.T) { - root := runtimeFixture(t) - installFakeMise(t) - system := os.Getenv("ONE_MISE_BINARY") - t.Setenv("ONE_MISE_BINARY", "") - t.Setenv("PATH", filepath.Dir(system)) - disableMiseNetwork(t) - out, stderr, code := runBinaryIn(t, root, "mise", "--version") - if code != 0 || !strings.HasPrefix(out, "2026.9.7") || strings.Contains(stderr, "Downloading") { - t.Fatalf("system mise: %d %q %s", code, out, stderr) - } - if _, err := os.Stat(filepath.Join(root, ".local/share/one/runtimes/mise")); !os.IsNotExist(err) { - t.Fatal("system selection installed a managed runtime") - } - if err := os.Remove(system); err != nil { - t.Fatal(err) - } - out, stderr, code = runBinaryIn(t, root, "mise", "--version") - if code == 0 || !strings.Contains(stderr, "Downloading official mise") || !strings.Contains(out+stderr, "MISE_INSTALL_FAILED") { - t.Fatalf("deleted system/offline: %d %q %s", code, out, stderr) - } - if _, err := os.Stat(filepath.Join(root, ".local/share/one/runtimes/mise/2026.9.7")); err != nil { - t.Fatal("runtime did not attempt managed bootstrap") - } -} - -// An opt-in official pinned binary checks real execution without putting mise -// back into build inputs. Other compatible ONE_TEST_MISE_BINARY versions still -// exercise configuration/trust tests, but cannot satisfy the managed digest. -func TestE2E_MiseManagedLegacyMigrationAndOfflineReuse(t *testing.T) { - source := os.Getenv("ONE_TEST_MISE_BINARY") - if source == "" { - t.Skip("set ONE_TEST_MISE_BINARY to the pinned official mise for managed integration") - } - a, err := miserelease.ForPlatform(runtime.GOOS, runtime.GOARCH) - if err != nil { - t.Skip(err) - } - raw, err := os.ReadFile(source) - if err != nil { - t.Fatal(err) - } - if fmt.Sprintf("%x", sha256.Sum256(raw)) != a.BinarySHA256 { - t.Skip("managed integration requires the pinned official binary digest") - } - root := runtimeFixture(t) - isolateMiseState(t, root) - t.Setenv("PATH", t.TempDir()) - disableMiseNetwork(t) - legacy := filepath.Join(root, ".cache/one/runtimes/mise", miserelease.Version, a.Platform, a.BinaryName()) - if err := os.MkdirAll(filepath.Dir(legacy), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(legacy, raw, 0o755); err != nil { - t.Fatal(err) - } - t.Setenv("MISE_AUTO_UPDATE", "true") - t.Setenv("MISE_DISABLE_UPDATE_WARNING", "false") - out, stderr, code := runBinaryIn(t, root, "mise", "--version") - if code != 0 || !strings.HasPrefix(out, miserelease.Version) || !strings.Contains(stderr, "Migrating verified mise") { - t.Fatalf("migration: %d %q %s", code, out, stderr) - } - managed := filepath.Join(root, ".local/share/one/runtimes/mise", miserelease.Version, a.Platform, a.BinaryName()) - if _, err := os.Stat(managed); err != nil { - t.Fatal(err) - } - if _, err := os.Stat(legacy); err != nil { - t.Fatal("migration removed legacy binary") - } - out, stderr, code = runBinaryIn(t, root, "mise", "--version") - if code != 0 || !strings.HasPrefix(out, miserelease.Version) || strings.Contains(stderr, "Migrating") || strings.Contains(stderr, "Downloading") { - t.Fatalf("offline reuse: %d %q %s", code, out, stderr) - } - // Pin the current shell path before hiding system mise; this command checks - // real mise directory settings and update flags through its exec environment. - shell := "/bin/sh" - if runtime.GOOS != "windows" { - args := []string{"exec", "web", "--", shell, "-c", `printf '%s|%s|%s|%s|%s|%s' "$MISE_DATA_DIR" "$MISE_CONFIG_DIR" "$MISE_STATE_DIR" "$MISE_CACHE_DIR" "$MISE_AUTO_UPDATE" "$MISE_DISABLE_UPDATE_WARNING"`} - out, stderr, code = runBinaryIn(t, root, args...) - want := strings.Join([]string{filepath.Join(root, ".local/share/one/mise"), filepath.Join(root, ".config/one/mise"), filepath.Join(root, ".local/state/one/mise"), filepath.Join(root, ".cache/one/mise"), "false", "true"}, "|") - if code != 0 || out != want { - t.Fatalf("managed environment: %d %q want %q %s", code, out, want, stderr) - } - } -} - -func TestE2E_MiseCreateAddAndRefreshWithoutNewFlags(t *testing.T) { - tmp := t.TempDir() - isolateHome(t, tmp) - t.Setenv("ONE_RUNTIME", "") - root := bootstrapWorkspace(t, tmp, "original-commands") - for _, p := range []struct{ template, name string }{{"react-spa", "web"}, {"go-api", "api"}} { - out, stderr, code := runBinaryIn(t, root, "add", p.template, "--name", p.name, "-y", "-o", "json") - if code != 0 { - t.Fatalf("add %s: %d %s %s", p.name, code, out, stderr) - } - } - for _, rel := range []string{"mise.toml"} { - raw, err := os.ReadFile(filepath.Join(root, rel)) - if err != nil || !strings.Contains(string(raw), "# one:managed-v1") { - t.Fatalf("configuration %s: %v %s", rel, err, raw) - } - } - for _, rel := range []string{"apps/web/mise.toml", "services/api/mise.toml"} { - if fileExists(t, filepath.Join(root, rel)) { - t.Fatalf("unexpected project configuration: %s", rel) - } - } - out, stderr, code := runBinaryIn(t, root, "init", "mise", "--dry-run", "-o", "json") - if code != 0 || !strings.Contains(out, `"changes": []`) { - t.Fatalf("refresh: %d %s %s", code, out, stderr) - } - out, stderr, code = runBinaryIn(t, root, "exec", "web", "--dry-run", "--", "node", "--version") - if code != 0 || !strings.Contains(out, `"runtime": "mise"`) { - t.Fatalf("automatic runtime: %d %s %s", code, out, stderr) - } - // A conflicting aggregate task edit must fail before rendering a new project. - path := filepath.Join(root, "mise.toml") - raw, _ := os.ReadFile(path) - if err := os.WriteFile(path, []byte(strings.Replace(string(raw), "//:web:build", "//:custom:build", 1)), 0o644); err != nil { - t.Fatal(err) - } - manifest, _ := os.ReadFile(filepath.Join(root, "one.manifest.toml")) - _, _, code = runBinaryIn(t, root, "add", "react-spa", "--name", "another", "-y") - if code == 0 { - t.Fatal("add succeeded with conflicting configuration") - } - after, _ := os.ReadFile(filepath.Join(root, "one.manifest.toml")) - if string(after) != string(manifest) { - t.Fatal("conflict changed manifest") - } - if _, err := os.Stat(filepath.Join(root, "apps/another")); !os.IsNotExist(err) { - t.Fatal("conflict created a project") - } -} - -func TestE2E_MiseNativeTasksDoNotRequireOneContext(t *testing.T) { - mise := os.Getenv("ONE_TEST_MISE_BINARY") - if mise == "" { - t.Skip("set ONE_TEST_MISE_BINARY for real task integration") - } - tmp := t.TempDir() - isolateHome(t, tmp) - t.Setenv("ONE_RUNTIME", "") - root := bootstrapWorkspace(t, tmp, "generated-tasks") - useRealMise(t, root, mise) - if out, stderr, code := runBinaryIn(t, root, "add", "react-spa", "--name", "web", "-y"); code != 0 { - t.Fatalf("add: %d %s %s", code, out, stderr) - } - // Edit the generated configuration in place, preserving monorepo fields. - configPath := filepath.Join(root, "mise.toml") - configRaw, err := os.ReadFile(configPath) - if err != nil { - t.Fatal(err) - } - configText := strings.ReplaceAll(string(configRaw), "node = '24.21.0'", "node = 'system'") - configText = strings.ReplaceAll(configText, "pnpm = '12.3.4'", "pnpm = 'system'") - configText += "\n[env]\nTASK_DEFAULT = 'mise'\n" - if err := os.WriteFile(configPath, []byte(configText), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, "apps/web/.env"), []byte("TASK_DEFAULT=one\n"), 0o644); err != nil { - t.Fatal(err) - } - binDir := filepath.Join(t.TempDir(), "one with spaces") - if err := os.MkdirAll(binDir, 0755); err != nil { - t.Fatal(err) - } - bin := filepath.Join(binDir, "one") - if runtime.GOOS == "windows" { - bin += ".exe" - } - raw, err := os.ReadFile(binaryPath(t)) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(bin, raw, 0o755); err != nil { - t.Fatal(err) - } - t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) - for _, command := range []string{"echo live-first", "echo live-second"} { - overrideDevCommand(t, root, "web", command) - cmd := exec.Command(mise, "run", "//:web:dev") - cmd.Dir = root - out, err := cmd.CombinedOutput() - if err != nil || !strings.Contains(string(out), strings.TrimPrefix(command, "echo ")) { - t.Fatalf("generated task: %v %s", err, out) - } - } -} diff --git a/packages/cli/tests/e2e/mise_runtime_unix_test.go b/packages/cli/tests/e2e/mise_runtime_unix_test.go deleted file mode 100644 index 2c49f20d..00000000 --- a/packages/cli/tests/e2e/mise_runtime_unix_test.go +++ /dev/null @@ -1,202 +0,0 @@ -//go:build unix - -package cli_test - -import ( - "net" - "os" - "os/exec" - "path/filepath" - "strings" - "syscall" - "testing" - "time" -) - -func TestE2E_MiseDevStopsBothProjects(t *testing.T) { - // Reproduce invocation from an outer mise task, including Git hook gates. - t.Setenv("MISE_TASK_PGID_MANAGED", "1") - node, err := exec.LookPath("node") - if err != nil { - t.Skip("node required for process integration") - } - for _, mode := range []string{"interrupt", "terminate", "child-failure"} { - t.Run(mode, func(t *testing.T) { - root := devTerminalFixture(t, true) - for _, project := range []string{"web", "lib"} { - overrideDevCommand(t, root, project, "'"+strings.ReplaceAll(node, "'", "'\"'\"'")+"' dev.cjs") - } - script := `const fs = require('node:fs'); -const net = require('node:net'); -const server = net.createServer(); -server.listen(0, '127.0.0.1', () => fs.writeFileSync('ready', String(server.address().port))); -setInterval(() => { if (fs.existsSync('fail')) process.exit(7); }, 20); -` - for _, rel := range []string{"apps/web", "packages/lib"} { - if err := os.WriteFile(filepath.Join(root, rel, "dev.cjs"), []byte(script), 0o644); err != nil { - t.Fatal(err) - } - } - cmd := exec.Command(binaryPath(t), "dev") - cmd.Dir = root - cmd.Env = os.Environ() - var log lockedBuffer - cmd.Stdout, cmd.Stderr = &log, &log - if err := cmd.Start(); err != nil { - t.Fatal(err) - } - done := make(chan error, 1) - go func() { done <- cmd.Wait() }() - finished := false - defer func() { - if !finished { - _ = cmd.Process.Signal(syscall.SIGTERM) - select { - case <-done: - case <-time.After(8 * time.Second): - _ = cmd.Process.Kill() - <-done - } - } - }() - ports := []string{} - deadline := time.Now().Add(10 * time.Second) - for _, rel := range []string{"apps/web", "packages/lib"} { - for { - if raw, err := os.ReadFile(filepath.Join(root, rel, "ready")); err == nil { - ports = append(ports, strings.TrimSpace(string(raw))) - break - } - select { - case err := <-done: - finished = true - t.Fatalf("dev stopped before ready: %v\n%s", err, log.String()) - default: - } - if time.Now().After(deadline) { - t.Fatal("dev did not start both projects") - } - time.Sleep(20 * time.Millisecond) - } - } - if mode == "interrupt" { - if err := cmd.Process.Signal(os.Interrupt); err != nil { - t.Fatal(err) - } - } else if mode == "terminate" { - if err := cmd.Process.Signal(syscall.SIGTERM); err != nil { - t.Fatal(err) - } - } else { - if err := os.WriteFile(filepath.Join(root, "apps/web/fail"), nil, 0o644); err != nil { - t.Fatal(err) - } - } - select { - case err := <-done: - finished = true - want := 7 - if mode == "interrupt" { - want = 130 - } - if mode == "terminate" { - want = 143 - } - if exit, ok := err.(*exec.ExitError); !ok || exit.ExitCode() != want { - t.Fatalf("exit=%v want=%d\n%s", err, want, log.String()) - } - case <-time.After(8 * time.Second): - t.Fatalf("dev did not stop\n%s", log.String()) - } - for _, port := range ports { - // SIGKILL delivery and the kernel closing descendant sockets are - // asynchronous. Require closure within a bounded interval. - deadline := time.Now().Add(time.Second) - for { - conn, err := net.DialTimeout("tcp", "127.0.0.1:"+port, 100*time.Millisecond) - if err != nil { - break - } - conn.Close() - if time.Now().After(deadline) { - t.Errorf("project still listening on %s after dev stopped", port) - break - } - time.Sleep(10 * time.Millisecond) - } - } - }) - } -} - -func TestE2E_ExecWithoutInternalWorkerCancelsDescendants(t *testing.T) { - node, err := exec.LookPath("node") - if err != nil { - t.Skip("node required") - } - for _, sig := range []syscall.Signal{syscall.SIGINT, syscall.SIGTERM} { - t.Run(sig.String(), func(t *testing.T) { - root := buildFixture(t, true) - script := `const fs=require('node:fs');const net=require('node:net'); -if (!process.env.EXEC_GRANDCHILD) {require('node:child_process').spawn(process.execPath,[__filename],{env:{...process.env,EXEC_GRANDCHILD:'1'},stdio:'inherit'});setInterval(()=>{},1000)} -else {net.createServer().listen(0,'127.0.0.1',function(){fs.writeFileSync('exec-ready',String(this.address().port))})} -` - buildWrite(t, root, "apps/web/exec.cjs", script) - cmd := exec.Command(binaryPath(t), "exec", "web", "--", node, "exec.cjs") - cmd.Dir = root - cmd.Env = os.Environ() - var log lockedBuffer - cmd.Stdout, cmd.Stderr = &log, &log - if err := cmd.Start(); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = cmd.Process.Kill() }) - done := make(chan error, 1) - go func() { done <- cmd.Wait() }() - var port string - deadline := time.Now().Add(8 * time.Second) - for port == "" && time.Now().Before(deadline) { - if raw, err := os.ReadFile(filepath.Join(root, "apps/web/exec-ready")); err == nil { - port = string(raw) - break - } - select { - case err := <-done: - t.Fatalf("exec stopped: %v %s", err, log.String()) - default: - } - time.Sleep(10 * time.Millisecond) - } - if port == "" { - t.Fatalf("exec never ready: %s", log.String()) - } - if err := cmd.Process.Signal(sig); err != nil { - t.Fatal(err) - } - select { - case err := <-done: - want := 130 - if sig == syscall.SIGTERM { - want = 143 - } - if exit, ok := err.(*exec.ExitError); !ok || exit.ExitCode() != want { - t.Fatalf("exit %v, want %d: %s", err, want, log.String()) - } - case <-time.After(8 * time.Second): - t.Fatalf("exec did not stop: %s", log.String()) - } - deadline = time.Now().Add(time.Second) - for { - conn, err := net.DialTimeout("tcp", "127.0.0.1:"+port, 100*time.Millisecond) - if err != nil { - break - } - conn.Close() - if time.Now().After(deadline) { - t.Fatal("exec grandchild still listening") - } - time.Sleep(10 * time.Millisecond) - } - }) - } -} diff --git a/packages/cli/tests/e2e/mise_trust_test.go b/packages/cli/tests/e2e/mise_trust_test.go deleted file mode 100644 index 42d234e2..00000000 --- a/packages/cli/tests/e2e/mise_trust_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package cli_test - -import ( - "os" - "os/exec" - "path/filepath" - "strings" - "testing" -) - -func TestE2E_CreateAndAddTrustGeneratedMiseFiles(t *testing.T) { - mise, err := exec.LookPath("mise") - if err != nil { - t.Skip("real mise is required") - } - temp := t.TempDir() - isolateHome(t, filepath.Join(temp, "home")) - t.Setenv("ONE_MISE_BINARY", mise) - t.Setenv("MISE_PARANOID", "1") - t.Setenv("MISE_TRUSTED_CONFIG_PATHS", "") - // Both mise-action and mise's CI mode enable automatic confirmation, - // bypassing the trust checks this test observes even in paranoid mode. - t.Setenv("MISE_YES", "0") - t.Setenv("CI", "false") - parent := filepath.Join(temp, "workspaces") - buildWrite(t, parent, "mise.toml", "[env]\nPARENT_USER_CONFIG='unchanged'\n") - checkTrust := func(path string, want bool) { - t.Helper() - command := exec.Command(mise, "trust", "--show", path) - command.Dir, command.Env = filepath.Dir(path), os.Environ() - out, err := command.CombinedOutput() - trusted := strings.HasSuffix(strings.TrimSpace(string(out)), ": trusted") - if err != nil || trusted != want { - t.Fatalf("trust(%s)=%s, %v; want %v", path, out, err, want) - } - } - run := func(root string, args ...string) { - t.Helper() - stdout, stderr, code := runBinaryIn(t, root, args...) - if code != 0 || strings.Contains(stdout, "\"warnings\"") { - t.Fatalf("%v: %d %s %s", args, code, stdout, stderr) - } - } - ws := filepath.Join(parent, "demo") - run(parent, "create", ws, "--yes", "-o", "json") - checkTrust(filepath.Join(ws, "mise.toml"), true) - checkTrust(filepath.Join(parent, "mise.toml"), false) - run(ws, "add", "react-spa", "--name", "web", "--yes", "-o", "json") - checkTrust(filepath.Join(ws, "mise.toml"), true) - if fileExists(t, filepath.Join(ws, "apps/web/mise.toml")) { - t.Fatal("unexpected project mise configuration") - } - // Adding a project must not grant trust to unrelated custom configuration. - path := filepath.Join(ws, "mise.toml") - raw, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - buildWrite(t, ws, "mise.toml", string(raw)+"\n[env]\nUSER_OVERRIDE='preserve'\n") - checkTrust(path, false) - run(ws, "add", "ts-library", "--name", "shared", "--yes", "-o", "json") - checkTrust(path, false) - if fileExists(t, filepath.Join(ws, "packages/shared/mise.toml")) { - t.Fatal("unexpected project mise configuration") - } - multi := filepath.Join(parent, "multi") - run(parent, "create", multi, "--yes", "-o", "json") - run(multi, "add", "react-spa", "--name", "web", "--yes", "-o", "json") - run(multi, "add", "ts-library", "--name", "shared", "--yes", "-o", "json") - checkTrust(filepath.Join(multi, "mise.toml"), true) - if fileExists(t, filepath.Join(multi, "apps/web/mise.toml")) { - t.Fatal("unexpected project mise configuration") - } - if fileExists(t, filepath.Join(multi, "packages/shared/mise.toml")) { - t.Fatal("unexpected project mise configuration") - } -} - -func TestE2E_CreatePreservesFilesWhenMiseTrustFails(t *testing.T) { - for _, locale := range []string{"en_US.UTF-8", "zh_CN.UTF-8"} { - t.Run(locale, func(t *testing.T) { - temp := t.TempDir() - isolateHome(t, filepath.Join(temp, "home")) - t.Setenv("LC_ALL", locale) - t.Setenv("ONE_MISE_BINARY", filepath.Join(temp, "missing-mise")) - root := filepath.Join(temp, "demo") - stdout, stderr, code := runBinary(t, "create", root, "--yes", "-o", "json") - if code != 0 { - t.Fatalf("creation failed: %d %s %s", code, stdout, stderr) - } - result := mustParseJSON(t, stdout) - warnings, ok := result["warnings"].([]any) - if !ok || len(warnings) != 1 || !strings.Contains(warnings[0].(string), "one mise trust") { - t.Fatal(result) - } - if strings.HasPrefix(locale, "zh") && !strings.Contains(warnings[0].(string), "自动信任") { - t.Fatal(warnings) - } - if !fileExists(t, filepath.Join(root, "mise.toml")) || !fileExists(t, filepath.Join(root, "one.manifest.toml")) { - t.Fatal("creation was rolled back") - } - }) - } -} diff --git a/packages/cli/tests/e2e/process_compose_test.go b/packages/cli/tests/e2e/process_compose_test.go deleted file mode 100644 index be6386c5..00000000 --- a/packages/cli/tests/e2e/process_compose_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package cli_test - -import ( - "encoding/json" - tasks "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/tasks" - "os" - "path/filepath" - "strings" - "testing" -) - -func TestE2E_ProcessComposeOrdersSilentStepsWaitsAndSharedAliases(t *testing.T) { - root := buildFixture(t, true) - appendRootTaskConfig(t, root, ` - [tasks.compose-demo] - depends=["first","second","silent"] - [tasks.seed] - alias="same-seed" - run="echo seed >> order; touch ready" - [tasks.first] - depends=["seed","same-seed"] - run=["test -f ready; echo first-1 >> order", "sleep .1", "echo first-2 >> order"] - [tasks.second] - wait_for=["first"] - run="echo second >> order" - [tasks.silent] - run="sleep .1" - [tasks.unselected] - run="{{ unsupported_expression }}" - `) - out, stderr, code := runBinaryIn(t, root, "run", "compose-demo", "-o", "json") - if code != 0 { - t.Fatalf("%d %s %s", code, out, stderr) - } - var result tasks.Result - if err := json.Unmarshal([]byte(out), &result); err != nil { - t.Fatal(err) - } - if len(result.Tasks) != 5 { - t.Fatal(result.Tasks) - } - for _, task := range result.Tasks { - if task.Status != "succeeded" { - t.Fatal(task) - } - } - raw, err := os.ReadFile(filepath.Join(root, "order")) - if err != nil || string(raw) != "seed\nfirst-1\nfirst-2\nsecond\n" { - t.Fatalf("order=%q err=%v", raw, err) - } -} - -func TestE2E_ProcessComposeFailsBeforeUnsupportedExecution(t *testing.T) { - root := buildFixture(t, true) - appendRootTaskConfig(t, root, ` - [tasks.unsupported-demo] - run="touch SHOULD_NOT_RUN" - depends=[{ task="seed", args=["value"] }] - [tasks.seed] - run="touch SHOULD_NOT_RUN" - `) - out, stderr, code := runBinaryIn(t, root, "run", "unsupported-demo", "-o", "json") - if code == 0 || !strings.Contains(out+stderr, "depends") { - t.Fatalf("%d %s %s", code, out, stderr) - } - if _, err := os.Stat(filepath.Join(root, "SHOULD_NOT_RUN")); !os.IsNotExist(err) { - t.Fatal("unsupported graph executed") - } - if _, _, code := runBinaryIn(t, root, "run", "build", "--concurrency", "1"); code == 0 { - t.Fatal("unsupported limit accepted") - } -} diff --git a/packages/cli/tests/e2e/repository_workspace_test.go b/packages/cli/tests/e2e/repository_workspace_test.go deleted file mode 100644 index 526ba330..00000000 --- a/packages/cli/tests/e2e/repository_workspace_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package cli_test - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" -) - -// The repository must remain bootstrappable without One, while project-scoped -// CLI tasks must prepare the resources and binary that Go alone cannot build. -func TestE2E_RepositoryWorkspaceTaskGraph(t *testing.T) { - isolateHome(t, t.TempDir()) - root := filepath.Clean(filepath.Join(repoRoot(t), "..", "..")) - for _, dir := range []string{"apps/dashboard", "apps/docs", "packages/cli", "packages/kernel"} { - if _, err := os.Stat(filepath.Join(root, dir, "mise.toml")); !os.IsNotExist(err) { - t.Fatalf("repository must use only root mise.toml: %s: %v", dir, err) - } - } - for _, tc := range []struct { - name string - args []string - required []string - bootstrap bool - }{ - {"bootstrap", []string{"install"}, []string{"//:build-local", "//:sync-bundled", "//:sync-web"}, true}, - {"gate", []string{"check"}, []string{"//:build", "//:test:go:plain", "//:test:dashboard"}, true}, - {"cli build", []string{"build", "-p", "cli"}, []string{"//:sync-bundled", "//:build-web", "//:sync-web", "//:cli:build"}, false}, - {"cli test", []string{"test", "-p", "cli"}, []string{"//:build", "//:cli:test"}, false}, - {"development", []string{"dev"}, []string{"//:cli:dev", "//:dashboard:dev"}, true}, - {"docs development", []string{"dev", "-p", "docs"}, []string{"//:install-deps", "//:docs:dev"}, false}, - {"docs build", []string{"build", "-p", "docs"}, []string{"//:install-deps", "//:docs:build"}, false}, - } { - t.Run(tc.name, func(t *testing.T) { - args := append(append([]string{}, tc.args...), "--dry-run", "-o", "json") - out, logs, code := runBinaryIn(t, root, args...) - if code != 0 { - t.Fatal(code, out, logs) - } - var plan struct { - Tasks []struct { - Name string `json:"name"` - Managed bool `json:"managed"` - } `json:"tasks"` - Changes []json.RawMessage `json:"config_changes"` - } - if err := json.Unmarshal([]byte(out), &plan); err != nil { - t.Fatal(err) - } - if len(plan.Changes) != 0 { - t.Fatal("repository task configuration is stale; run one init mise") - } - names := map[string]bool{} - for _, task := range plan.Tasks { - names[task.Name] = true - if tc.bootstrap && task.Managed { - t.Errorf("native root workflow requires an installed One adapter: %s", task.Name) - } - if strings.Contains(task.Name, "packages/templates/") || strings.Contains(task.Name, "/testdata/") { - t.Errorf("source fixture became a runnable workspace project: %s", task.Name) - } - } - for _, name := range tc.required { - if !names[name] { - t.Errorf("missing prerequisite %s in %v", name, names) - } - } - }) - } -} diff --git a/packages/cli/tests/e2e/secret_output_test.go b/packages/cli/tests/e2e/secret_output_test.go deleted file mode 100644 index d5d3c6fd..00000000 --- a/packages/cli/tests/e2e/secret_output_test.go +++ /dev/null @@ -1,135 +0,0 @@ -package cli_test - -import ( - "bytes" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "os" - "os/exec" - "runtime" - "strings" - "sync/atomic" - "testing" - "time" - - cli "github.com/torchstellar-team/one-cli/packages/cli/internal/bootstrap/cli" - session "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/infisicalsession" - process "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/process" - "github.com/zalando/go-keyring" -) - -// Run the production command tree in a fresh test process with an in-memory -// keyring. No production auth override or developer keyring is used. -func TestAuthenticatedCLIHelper(t *testing.T) { - site := os.Getenv("ONE_TEST_SECRET_SERVER") - if site == "" { - return - } - keyring.MockInit() - raw, _ := json.Marshal(session.Session{Info: session.Info{SiteURL: site, ExpiresAt: time.Now().Add(time.Hour)}, Token: "test-session-token"}) - if err := keyring.Set("one-cli.infisical", "session", string(raw)); err != nil { - t.Fatal(err) - } - for i, arg := range os.Args { - if arg == "--" { - if err := cli.Execute("0.0.0-dev", os.Args[i+1:]); err != nil { - os.Exit(process.ExitCode(err)) - } - os.Exit(0) - } - } - t.Fatal("missing command arguments") -} -func runAuthenticatedCLI(t *testing.T, root, site string, args ...string) (string, string, int) { - t.Helper() - cmd := exec.Command(os.Args[0], append([]string{"-test.run=^TestAuthenticatedCLIHelper$", "--"}, args...)...) - cmd.Dir = root - cmd.Env = append(os.Environ(), "ONE_TEST_SECRET_SERVER="+site) - var out, errOut bytes.Buffer - cmd.Stdout, cmd.Stderr = &out, &errOut - code := 0 - if err := cmd.Run(); err != nil { - if exit, ok := err.(*exec.ExitError); ok { - code = exit.ExitCode() - } else { - t.Fatal(err) - } - } - return out.String(), errOut.String(), code -} -func TestE2E_SecretManagementNeverPrintsValues(t *testing.T) { - const secret = "test-private-credential-never-print" - var fail atomic.Bool - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if r.URL.Path == "/api/v1/workspace/test-project" { - _ = json.NewEncoder(w).Encode(map[string]any{"workspace": map[string]any{"id": "test-project", "type": "secret-manager", "environments": []any{map[string]string{"slug": "dev"}}}}) - return - } - if strings.Contains(r.URL.Path, "folders") { - _, _ = w.Write([]byte(`{"folders":[]}`)) - return - } - if !strings.HasPrefix(r.URL.Path, "/api/v3/secrets/raw") { - t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) - w.WriteHeader(404) - return - } - if fail.Load() { - w.WriteHeader(400) - _ = json.NewEncoder(w).Encode(map[string]string{"message": "rejected " + secret + " test-session-token"}) - return - } - value := map[string]string{"secretKey": "TEST_KEY", "secretValue": secret} - if r.URL.Path == "/api/v3/secrets/raw" { - _ = json.NewEncoder(w).Encode(map[string]any{"secrets": []any{value}}) - } else { - _ = json.NewEncoder(w).Encode(map[string]any{"secret": value}) - } - })) - defer server.Close() - root := t.TempDir() - isolateHome(t, t.TempDir()) - buildWrite(t, root, "one.manifest.toml", fmt.Sprintf(`version = 2 -[workspace] -id = "test" -name = "test" -[env.infisical] -siteUrl = %q -projectId = "test-project" -environments = ["dev"] -`, server.URL)) - if out, errOut, code := runAuthenticatedCLI(t, root, server.URL, "env", "bind", "--global", "--project-id", "test-project", "--env", "dev", "-o", "json"); code != 0 { - t.Fatalf("bind: %s %s", out, errOut) - } - for _, locale := range []string{"en_US.UTF-8", "zh_CN.UTF-8"} { - t.Setenv("LC_ALL", locale) - for _, format := range []string{"text", "json", "yaml"} { - for _, command := range [][]string{{"env", "set", "TEST_KEY", secret, "--yes"}, {"env", "list"}, {"env", "set", "TEST_KEY", secret, "--global", "--path", "/", "--env", "dev", "--yes"}, {"env", "list", "--global", "--path", "/", "--env", "dev"}} { - for _, failing := range []bool{false, true} { - fail.Store(failing) - out, errOut, code := runAuthenticatedCLI(t, root, server.URL, append(command, "-o", format)...) - if (code != 0) != failing { - t.Fatalf("command failed unexpectedly: %d %s %s", code, out, errOut) - } - if strings.Contains(out+errOut, secret) || strings.Contains(out+errOut, "test-session-token") { - t.Fatal("secret or session token leaked") - } - if !failing && format != "text" && !strings.Contains(out, "TEST_KEY") { - t.Fatalf("missing key metadata: %s %s", out, errOut) - } - } - } - } - } - - if runtime.GOOS != "windows" { - fail.Store(false) - out, errOut, code := runAuthenticatedCLI(t, root, server.URL, "exec", "--global", "--path", "/", "--env", "dev", "--", "sh", "-c", `printf '%s\n' "$TEST_KEY"; printf '%s\n' "$TEST_KEY" >&2; exit 13`) - if code != 13 || !strings.Contains(out, secret) || !strings.Contains(errOut, secret) || strings.Contains(out+errOut, "test-session-token") { - t.Fatalf("unexpected raw global exec: %d %s %s", code, out, errOut) - } - } -} diff --git a/packages/cli/tests/e2e/serve_browser_linux_test.go b/packages/cli/tests/e2e/serve_browser_linux_test.go deleted file mode 100644 index add23db5..00000000 --- a/packages/cli/tests/e2e/serve_browser_linux_test.go +++ /dev/null @@ -1,100 +0,0 @@ -//go:build linux - -package cli_test - -import ( - "net/http" - "os" - "os/exec" - "path/filepath" - "strings" - "syscall" - "testing" - "time" -) - -func TestE2E_ServeWithBlockingBrowser(t *testing.T) { - for _, locale := range []string{"en_US.UTF-8", "zh_CN.UTF-8"} { - t.Run(locale, func(t *testing.T) { - bin := binaryPath(t) - root := t.TempDir() - isolateHome(t, root) - t.Setenv("LC_ALL", locale) - // Model a desktop opener that keeps running while its browser is - // open. Never launch the developer's actual browser from a test. - opener := "#!/bin/sh\nprintf '%s' \"$1\" > browser-url\nwhile :; do sleep 1; done\n" - if err := os.WriteFile(filepath.Join(root, "xdg-open"), []byte(opener), 0o755); err != nil { - t.Fatal(err) - } - t.Setenv("PATH", root+string(os.PathListSeparator)+os.Getenv("PATH")) - log, err := os.Create(filepath.Join(root, "serve.log")) - if err != nil { - t.Fatal(err) - } - defer log.Close() - cmd := exec.Command(bin, "serve", "--port", "0", "-o", "text") - cmd.Dir = root - cmd.Stdout, cmd.Stderr = log, log - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - if err := cmd.Start(); err != nil { - t.Fatal(err) - } - done := make(chan error, 1) - go func() { done <- cmd.Wait() }() - finished := false - defer func() { - // Also stop the fake opener, which deliberately outlives one. - _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) - if !finished { - <-done - } - }() - - var url string - deadline := time.Now().Add(5 * time.Second) - for url == "" { - if raw, err := os.ReadFile(filepath.Join(root, "browser-url")); err == nil { - url = strings.TrimSpace(string(raw)) - } - if url != "" { - break - } - select { - case err := <-done: - finished = true - t.Fatalf("serve exited before opening browser: %v", err) - default: - } - if time.Now().After(deadline) { - t.Fatal("serve did not launch the browser") - } - time.Sleep(10 * time.Millisecond) - } - - client := &http.Client{Timeout: 2 * time.Second} - for _, path := range []string{"", "api/catalog"} { - res, err := client.Get(url + path) - if err != nil { - t.Fatalf("HTTP request blocked while browser was open: %v", err) - } - res.Body.Close() - if res.StatusCode != http.StatusOK { - t.Fatalf("GET %s returned %d, want 200", path, res.StatusCode) - } - } - client.CloseIdleConnections() - if err := cmd.Process.Signal(os.Interrupt); err != nil { - t.Fatal(err) - } - select { - case err := <-done: - finished = true - if err != nil { - t.Fatalf("serve shutdown failed: %v", err) - } - case <-time.After(5 * time.Second): - t.Fatal("browser prevented serve from shutting down") - } - }) - } -} diff --git a/packages/cli/tests/e2e/services_unix_test.go b/packages/cli/tests/e2e/services_unix_test.go deleted file mode 100644 index 02806a13..00000000 --- a/packages/cli/tests/e2e/services_unix_test.go +++ /dev/null @@ -1,133 +0,0 @@ -//go:build unix - -package cli_test - -import ( - "encoding/json" - "fmt" - "io" - "net" - "net/http" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/devservice" - process "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/process" -) - -func TestDevHTTPHelper(t *testing.T) { - if os.Getenv("ONE_TEST_DEV_HTTP") != "1" { - return - } - listener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - os.Exit(1) - } - fmt.Fprintln(os.Stdout, "\x1b[32mReady http://"+listener.Addr().String()+"/\x1b[0m") - _ = http.Serve(listener, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, "service-ready") })) - os.Exit(0) -} -func TestE2E_DashboardServiceLifecycle(t *testing.T) { - root := devTerminalFixture(t, true) - contextDir := t.TempDir() - t.Setenv("TMPDIR", contextDir) - script := "exec env ONE_TEST_DEV_HTTP=1 '" + strings.ReplaceAll(os.Args[0], "'", "'\\''") + "' -test.run=^TestDevHTTPHelper$\n" - buildWrite(t, root, "packages/lib/dev.sh", script) - buildWrite(t, root, "apps/web/dev.sh", script) - cmd := exec.Command(binaryPath(t), "serve", "--port", "0", "--open=false", "-o", "json") - cmd.Dir = root - cmd.Env = prependPath(os.Environ(), strings.TrimSuffix(binaryPath(t), "/one")) - var out, errOut safeBuf - cmd.Stdout = &out - cmd.Stderr = &errOut - if err := cmd.Start(); err != nil { - t.Fatal(err) - } - done := make(chan error, 1) - go func() { done <- cmd.Wait() }() - t.Cleanup(func() { _ = process.StopTree(cmd.Process) }) - envelope := waitForEnvelope(t, &out, 5*time.Second, errOut.String) - base := envelope["url"].(string) + "api/workspace" - client := &http.Client{Timeout: 2 * time.Second} - request := func(method, project, action string) devservice.Snapshot { - t.Helper() - path := base + "/projects/" + project + "/service" - if action != "" { - path += "/" + action - } - req, _ := http.NewRequest(method, path, strings.NewReader(`{"environment":"dev"}`)) - response, err := client.Do(req) - if err != nil { - t.Fatal(err) - } - defer response.Body.Close() - raw, _ := io.ReadAll(response.Body) - if response.StatusCode >= 300 { - t.Fatalf("%d: %s", response.StatusCode, raw) - } - var state devservice.Snapshot - if err = json.Unmarshal(raw, &state); err != nil { - t.Fatal(err) - } - return state - } - wait := func(project string, predicate func(devservice.Snapshot) bool) devservice.Snapshot { - t.Helper() - var state devservice.Snapshot - for deadline := time.Now().Add(12 * time.Second); time.Now().Before(deadline); { - state = request("GET", project, "") - if predicate(state) { - return state - } - time.Sleep(50 * time.Millisecond) - } - t.Fatalf("service never became ready: %+v\n%s", state, errOut.String()) - return state - } - ready := func(s devservice.Snapshot) bool { - return s.Status == "running" && len(s.Endpoints) > 0 && s.Endpoints[0].Reachable - } - first := request("POST", "lib", "start") - if again := request("POST", "lib", "start"); again.ID != first.ID { - t.Fatal("duplicate process") - } - first = wait("lib", ready) - // A second project can prepare and run while the first is still alive. - request("POST", "web", "start") - web := wait("web", ready) - res, err := client.Get(first.Endpoints[0].URL) - if err != nil { - t.Fatal(err) - } - res.Body.Close() - request("POST", "lib", "restart") - second := wait("lib", func(s devservice.Snapshot) bool { return s.ID != first.ID && ready(s) }) - request("POST", "lib", "stop") - wait("lib", func(s devservice.Snapshot) bool { return s.Status == "stopped" }) - if err = cmd.Process.Signal(os.Interrupt); err != nil { - t.Fatal(err) - } - select { - case err = <-done: - if err != nil { - t.Fatal(err) - } - case <-time.After(9 * time.Second): - t.Fatal("Dashboard shutdown did not finish") - } - remaining, err := filepath.Glob(filepath.Join(contextDir, "one-task-context-*")) - if err != nil || len(remaining) != 0 { - t.Fatalf("task context survived shutdown: %v %v", remaining, err) - } - for _, s := range []devservice.Snapshot{first, second, web} { - conn, err := net.DialTimeout("tcp", strings.TrimSuffix(strings.TrimPrefix(s.Endpoints[0].URL, "http://"), "/"), 100*time.Millisecond) - if err == nil { - conn.Close() - t.Fatal("orphaned service port") - } - } -} diff --git a/packages/cli/tests/e2e/skills_terminal_unix_test.go b/packages/cli/tests/e2e/skills_terminal_unix_test.go deleted file mode 100644 index ca77017a..00000000 --- a/packages/cli/tests/e2e/skills_terminal_unix_test.go +++ /dev/null @@ -1,94 +0,0 @@ -//go:build unix - -package cli_test - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/charmbracelet/x/ansi" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" -) - -func TestE2E_AutomaticSkillsKeepLocalizedSpinnerClean(t *testing.T) { - t.Cleanup(func() { _ = i18n.Init(i18n.DefaultLocale) }) - for _, locale := range []struct{ id, env string }{{"en-US", "en_US.UTF-8"}, {"zh-CN", "zh_CN.UTF-8"}} { - t.Run(locale.id, func(t *testing.T) { - parent := t.TempDir() - isolateHome(t, t.TempDir()) - t.Setenv("LC_ALL", locale.env) - if err := i18n.Init(locale.id); err != nil { - t.Fatal(err) - } - tools := t.TempDir() - script := `#!/bin/sh -set -eu -printf 'SKILLS_UPSTREAM_BANNER\nTip: use --global\n' -printf 'UPSTREAM_STDERR_NOTICE\n' >&2 -selecting=no -for arg in "$@"; do - case "$arg" in - --skill) selecting=yes ;; - --agent) selecting=no ;; - *) if [ "$selecting" = yes ]; then - mkdir -p ".agents/skills/$arg" - printf 'fixture skill\n' > ".agents/skills/$arg/SKILL.md" - fi ;; - esac -done -sleep 0.3 -` - if err := os.WriteFile(filepath.Join(tools, "npx"), []byte(script), 0o755); err != nil { - t.Fatal(err) - } - t.Setenv("PATH", tools+string(os.PathListSeparator)+os.Getenv("PATH")) - created := startTaskTerminal(t, parent, "create", "demo", "--yes") - created.wait(t, 0) - assertSkillsSpinner(t, created.out.String(), "one-cli") - assertSkillsSpinner(t, created.out.String(), "find-skills") - root := filepath.Join(parent, "demo") - added := startTaskTerminal(t, root, "add", "go-lib", "--name", "shared", "--yes") - added.wait(t, 0) - assertSkillsSpinner(t, added.out.String(), "golang-patterns, golang-testing") - mobile := startTaskTerminal(t, root, "add", "expo-mobile", "--name", "mobile", "--yes") - mobile.wait(t, 0) - assertSkillsSpinner(t, mobile.out.String(), "expo-overview") - truncated := false - for _, fragment := range strings.Split(mobile.out.String(), "\r") { - line := ansi.Strip(fragment) - characters := []rune(line) - if len(characters) == 0 || characters[0] < 0x2800 || characters[0] > 0x28ff { - continue - } - if ansi.StringWidth(line) >= 110 { - t.Fatalf("spinner would wrap in the 110-column terminal: %q", line) - } - truncated = truncated || strings.HasSuffix(line, "…") - } - if !truncated { - t.Fatalf("long skill names were not truncated: %q", mobile.out.String()) - } - }) - } -} - -func assertSkillsSpinner(t *testing.T, got, names string) { - t.Helper() - for _, hidden := range []string{"SKILLS_UPSTREAM_BANNER", "Tip: use --global", "UPSTREAM_STDERR_NOTICE"} { - if strings.Contains(got, hidden) { - t.Fatalf("automatic install leaked upstream UI: %q", got) - } - } - message := i18n.Tf("skills.installing", names) - at := strings.Index(got, message) - if at < 0 { - t.Fatalf("missing localized skills progress %q: %q", message, got) - } - start := strings.LastIndex(got[:at], "\r") - if start < 0 || !strings.HasPrefix(got[start:at], "\r\x1b[2K") || strings.Contains(got[start:at], "\n") { - t.Fatalf("skills status did not replace the spinner on a cleared line: %q", got) - } -} diff --git a/packages/cli/tests/e2e/skills_test.go b/packages/cli/tests/e2e/skills_test.go deleted file mode 100644 index 55ef0953..00000000 --- a/packages/cli/tests/e2e/skills_test.go +++ /dev/null @@ -1,121 +0,0 @@ -package cli_test - -import ( - "bytes" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "testing" -) - -func TestE2E_CreateInstallsCommonSkillsInWorkspace(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("POSIX fake npx; cross-platform runner is covered by helper-process tests") - } - parent := t.TempDir() - home := t.TempDir() - isolateHome(t, home) - globalPath := filepath.Join(home, ".agents", "skills", "one-cli", "SKILL.md") - if err := os.MkdirAll(filepath.Dir(globalPath), 0o755); err != nil { - t.Fatal(err) - } - globalContent := []byte("user's global guidance") - if err := os.WriteFile(globalPath, globalContent, 0o644); err != nil { - t.Fatal(err) - } - tools := t.TempDir() - tracePath := filepath.Join(tools, "trace") - t.Setenv("ONE_SKILLS_TEST_TRACE", tracePath) - script := `#!/bin/sh -set -eu -printf '%s\n' "$PWD" "$@" >> "$ONE_SKILLS_TEST_TRACE" -printf 'SKILLS_UPSTREAM_BANNER\nTip: use --global\n' -printf 'UPSTREAM_STDERR_NOTICE\n' >&2 -for arg in "$@"; do - case "$arg" in --global|-g) exit 17 ;; esac -done -mkdir -p ".agents/skills/$6" -case "$6" in - one-cli) test "$4" = './.agents/skills'; test -s "$4/one-cli/SKILL.md" ;; - find-skills) printf 'discovery fixture\n' > .agents/skills/find-skills/SKILL.md ;; - *) exit 18 ;; -esac -` - if err := os.WriteFile(filepath.Join(tools, "npx"), []byte(script), 0o755); err != nil { - t.Fatal(err) - } - t.Setenv("PATH", tools+string(os.PathListSeparator)+os.Getenv("PATH")) - root := filepath.Join(parent, "demo") - cmd := exec.Command(binaryPath(t), "create", root, "--yes", "-o", "json") - cmd.Dir = parent - var out, stderr bytes.Buffer - cmd.Stdout, cmd.Stderr = &out, &stderr - if err := cmd.Run(); err != nil { - t.Fatalf("create: %v stdout=%s stderr=%s", err, &out, &stderr) - } - mustParseJSON(t, out.String()) - for _, hidden := range []string{"SKILLS_UPSTREAM_BANNER", "Tip: use --global", "UPSTREAM_STDERR_NOTICE"} { - if strings.Contains(out.String(), hidden) || strings.Contains(stderr.String(), hidden) { - t.Fatalf("automatic install leaked upstream UI: stdout=%s stderr=%s", &out, &stderr) - } - } - entries, err := os.ReadDir(filepath.Join(root, ".agents", "skills")) - if err != nil || len(entries) != 2 || entries[0].Name() != "find-skills" || entries[1].Name() != "one-cli" { - t.Fatalf("unexpected project skills: %v %v", entries, err) - } - localContent, err := os.ReadFile(filepath.Join(root, ".agents", "skills", "one-cli", "SKILL.md")) - if err != nil || len(localContent) == 0 || bytes.Equal(localContent, globalContent) { - t.Fatalf("global skill was used instead of a project installation: %v", err) - } - if _, err := os.Stat(filepath.Join(root, ".one")); !os.IsNotExist(err) { - t.Fatalf("create left a duplicate skill source directory: %v", err) - } - ignored := exec.Command("git", "check-ignore", "--no-index", "--stdin") - ignored.Dir = root - ignored.Stdin = strings.NewReader(".one/cache.json\n.agents/skills/one-cli/SKILL.md\nskills-lock.json\n") - if got, err := ignored.CombinedOutput(); err != nil || string(got) != ".one/cache.json\n" { - t.Fatalf("local state must be ignored while skills and their lock remain versionable: %q %v", got, err) - } - globalAfter, err := os.ReadFile(globalPath) - if err != nil || !bytes.Equal(globalAfter, globalContent) { - t.Fatalf("global guidance changed: %v", err) - } - trace, err := os.ReadFile(tracePath) - if err != nil { - t.Fatal(err) - } - canonical, err := filepath.EvalSymlinks(root) - if err != nil { - t.Fatal(err) - } - if strings.Count(string(trace), canonical+"\n") != 2 || strings.Count(string(trace), "skills@1.7.0\n") != 2 || !strings.Contains(string(trace), "\nfind-skills\n") || !strings.Contains(string(trace), "\nvercel-labs/skills#") || strings.Contains(string(trace), "--global") { - t.Fatalf("common skills did not install from their sources in the project: %s", trace) - } -} - -func TestE2E_SkillsPreservesUpstreamArgumentsHelpAndExitCode(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("POSIX fake npx; cross-platform runner is covered by helper-process tests") - } - root := t.TempDir() - isolateHome(t, t.TempDir()) - tools := t.TempDir() - script := "#!/bin/sh\npwd\nprintf '%s\\n' \"$@\"\nprintf 'native stderr\\n' >&2\nexit 37\n" - if err := os.WriteFile(filepath.Join(tools, "npx"), []byte(script), 0o755); err != nil { - t.Fatal(err) - } - t.Setenv("PATH", tools+string(os.PathListSeparator)+os.Getenv("PATH")) - canonical, err := filepath.EvalSymlinks(root) - if err != nil { - t.Fatal(err) - } - for _, args := range [][]string{{"--help"}, {"add", "owner/repo", "--skill", "a b", "", "$(literal)", "--global", "--yes", "--agent", "codex", "--output", "json"}} { - out, stderr, code := runBinaryIn(t, root, append([]string{"skills"}, args...)...) - want := canonical + "\n--yes\nskills@1.7.0\n" + strings.Join(args, "\n") + "\n" - if code != 37 || out != want || stderr != "native stderr\n" { - t.Fatalf("upstream semantics changed: code=%d out=%q stderr=%q, want %q", code, out, stderr, want) - } - } -} diff --git a/packages/cli/tests/e2e/snapshot_e2e_add_test.go b/packages/cli/tests/e2e/snapshot_e2e_add_test.go deleted file mode 100644 index a628ba27..00000000 --- a/packages/cli/tests/e2e/snapshot_e2e_add_test.go +++ /dev/null @@ -1,257 +0,0 @@ -package cli_test - -// E2E coverage of `one add` template mode. -// -// Ordinary `one add` renders a locally developable project and deliberately -// leaves deployment/container choices unset. The advanced -// --deploy-provider path remains available for automation. - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -// TestSnapshot_E2E_Add_DefersDeploymentDefaults locks the central UX rule: -// adding a project does not choose a deployment target or render deploy -// artifacts, even when the technology stack has historical defaults. -func TestSnapshot_E2E_Add_DefersDeploymentDefaults(t *testing.T) { - tmp := t.TempDir() - isolateHome(t, tmp) - ws := bootstrapWorkspace(t, tmp, "ws") - - // go-api is the simplest template that exercises a non-default - // toolchain (Go) without depending on node/pnpm at scaffold time. - // Its technology-stack entry supports container/docker + - // deploy/kustomize, but ordinary add must not select either one. - stdout, stderr, code := runBinaryIn(t, ws, "add", "go-api", "--name", "user-api", "-y", "-o", "json") - if code != 0 { - t.Fatalf("add failed: exit %d\n stdout: %s\n stderr: %s", code, stdout, stderr) - } - got := mustParseJSON(t, stdout) - if got["schema"] != "one-cli/add/v1" { - t.Errorf("schema: want one-cli/add/v1, got %v", got["schema"]) - } - if got["template_id"] != "go-api" { - t.Errorf("template_id: want go-api, got %v", got["template_id"]) - } - if got["toolchain"] != "go" { - t.Errorf("toolchain: want go, got %v", got["toolchain"]) - } - assertSnapshot(t, "add-go-api.json", got) - - // Subproject directory must exist with template artifacts. - svcDir := filepath.Join(ws, "services", "user-api") - for _, p := range []string{"go.mod", "Taskfile.yml", "cmd"} { - full := filepath.Join(svcDir, p) - if !fileExists(t, full) { - t.Errorf("expected template file missing: %s", full) - } - } - assertWorkspaceAgentDocs(t, ws) - assertNoAgentDocs(t, svcDir) - goModRaw, err := os.ReadFile(filepath.Join(svcDir, "go.mod")) - if err != nil { - t.Fatalf("read rendered go.mod: %v", err) - } - goMod := string(goModRaw) - for _, want := range []string{ - "github.com/swaggo/files v1.0.1", - "github.com/swaggo/gin-swagger v1.6.1", - } { - if !strings.Contains(goMod, want) { - t.Errorf("rendered go.mod missing Swagger dependency %q:\n%s", want, goMod) - } - } - routerRaw, err := os.ReadFile(filepath.Join(svcDir, "internal", "http", "router.go")) - if err != nil { - t.Fatalf("read rendered router.go: %v", err) - } - router := string(routerRaw) - for _, want := range []string{ - `swaggerFiles "github.com/swaggo/files"`, - `ginSwagger "github.com/swaggo/gin-swagger"`, - `engine.GET("/api/docs/*any", ginSwagger.WrapHandler(`, - `ginSwagger.URL("/api/openapi.yaml")`, - } { - if !strings.Contains(router, want) { - t.Errorf("rendered router.go missing Swagger wiring %q:\n%s", want, router) - } - } - if !strings.Contains(router, `c.Redirect(http.StatusTemporaryRedirect, "/api/docs/index.html")`) { - t.Errorf("rendered router should redirect /api/docs to Swagger UI:\n%s", router) - } - openAPIRaw, err := os.ReadFile(filepath.Join(svcDir, "api", "openapi.yaml")) - if err != nil { - t.Fatalf("read rendered openapi.yaml: %v", err) - } - if !strings.HasPrefix(string(openAPIRaw), "openapi: 3.0.3\n") { - t.Errorf("rendered openapi.yaml must use Swagger UI-compatible OpenAPI 3.0.x, got:\n%s", openAPIRaw) - } - - if fileExists(t, filepath.Join(svcDir, "Dockerfile")) { - t.Error("ordinary add must not render a Dockerfile before deployment is chosen") - } - if fileExists(t, filepath.Join(ws, ".github", "workflows", "ci-services-user-api.yml")) { - t.Error("ordinary add must not generate CI configuration") - } - for _, rel := range []string{ - "kustomize/base/user-api.yaml", - "kustomize/base/kustomization.yaml", - "kustomize/overlays/dev/kustomization.yaml", - "kustomize/overlays/staging/kustomization.yaml", - "kustomize/overlays/prod/kustomization.yaml", - } { - if fileExists(t, filepath.Join(ws, filepath.FromSlash(rel))) { - t.Errorf("ordinary add unexpectedly rendered deployment artifact: %s", rel) - } - } - - // Manifest lists project identity only; commands stay in mise. - mf := readManifest(t, ws) - subs, _ := mf["projects"].(map[string]any) - if len(subs) != 1 { - t.Fatalf("manifest: want 1 subproject, got %d", len(subs)) - } - sub0 := subs["user-api"].(map[string]any) - domains, _ := sub0["domains"].(map[string]any) - if _, ok := domains["container"]; ok { - t.Errorf("subproject.domains.container should be absent, got %v", domains["container"]) - } - if _, ok := domains["deploy"]; ok { - t.Errorf("subproject.domains.deploy should be absent, got %v", domains["deploy"]) - } - if _, exists := sub0["buildVersion"]; exists { - t.Fatal("retired buildVersion persisted") - } -} - -// TestSnapshot_E2E_Add_NoDefaultsTemplate locks the negative side of -// `defaults`: ts-library has no per-subproject defaults, so -// adding it under a default workspace must NOT produce a Dockerfile. -func TestSnapshot_E2E_Add_NoDefaultsTemplate(t *testing.T) { - tmp := t.TempDir() - isolateHome(t, tmp) - ws := bootstrapWorkspace(t, tmp, "ws") - - stdout, stderr, code := runBinaryIn(t, ws, "add", "ts-library", "--name", "shared", "-y", "-o", "json") - if code != 0 { - t.Fatalf("add failed: exit %d\n stderr: %s", code, stderr) - } - _ = stdout - - libDir := filepath.Join(ws, "packages", "shared") - if fileExists(t, filepath.Join(libDir, "Dockerfile")) { - t.Error("Dockerfile produced for ts-library (no defaults expected)") - } -} - -// TestSnapshot_E2E_Add_GoLibTemplate verifies the go-lib template -// renders the golang-standards/project-layout starter (pkg/greeter, -// go.mod, Taskfile.yml, LICENSE) under packages// -// and — like ts-library — does NOT auto-enable container/docker or -// deploy/kustomize because it declares no per-subproject defaults. -func TestSnapshot_E2E_Add_GoLibTemplate(t *testing.T) { - tmp := t.TempDir() - isolateHome(t, tmp) - ws := bootstrapWorkspace(t, tmp, "ws") - - stdout, stderr, code := runBinaryIn(t, ws, "add", "go-lib", "--name", "mathx", "-y", "-o", "json") - if code != 0 { - t.Fatalf("add failed: exit %d\n stdout: %s\n stderr: %s", code, stdout, stderr) - } - got := mustParseJSON(t, stdout) - if got["template_id"] != "go-lib" { - t.Errorf("template_id: want go-lib, got %v", got["template_id"]) - } - if got["toolchain"] != "go" { - t.Errorf("toolchain: want go, got %v", got["toolchain"]) - } - - libDir := filepath.Join(ws, "packages", "mathx") - for _, rel := range []string{ - "go.mod", - "Taskfile.yml", - "LICENSE", - filepath.Join("pkg", "greeter", "greeter.go"), - filepath.Join("pkg", "greeter", "greeter_test.go"), - } { - full := filepath.Join(libDir, rel) - if !fileExists(t, full) { - t.Errorf("expected go-lib artifact missing: %s", full) - } - } - assertWorkspaceAgentDocs(t, ws) - assertNoAgentDocs(t, libDir) - - // Dev-only go.mod must NOT leak into the rendered output. - if raw, err := os.ReadFile(filepath.Join(libDir, "go.mod")); err == nil { - if strings.Contains(string(raw), "template-go-lib") { - t.Errorf("rendered go.mod still contains dev-only module name 'template-go-lib':\n%s", raw) - } - if !strings.Contains(string(raw), "mathx") { - t.Errorf("rendered go.mod should reference project name 'mathx', got:\n%s", raw) - } - } else { - t.Errorf("read rendered go.mod: %v", err) - } - - // No defaults declared → no container/deploy artifacts. - if fileExists(t, filepath.Join(libDir, "Dockerfile")) { - t.Error("Dockerfile produced for go-lib (no defaults expected)") - } - if fileExists(t, filepath.Join(ws, "kustomize")) { - t.Error("kustomize/ produced for go-lib (no defaults expected)") - } - - // A library has no long-running local entrypoint. Declaring the Go API - // fallback here would make the workspace overview claim it can start and - // make `one dev` run a cmd/server directory the template does not contain. - mf := readManifest(t, ws) - projects, _ := mf["projects"].(map[string]any) - if len(projects) != 1 { - t.Fatalf("manifest: want 1 project, got %d", len(projects)) - } - var project map[string]any - for _, entry := range projects { - project = entry.(map[string]any) - } - if dev, exists := project["dev"]; exists { - t.Fatalf("go-lib must not declare a runnable dev command, got %v", dev) - } -} - -func TestAddPreservesExistingAgentDocs(t *testing.T) { - tmp := t.TempDir() - isolateHome(t, tmp) - ws := bootstrapWorkspace(t, tmp, "ws") - files := map[string]string{ - "AGENTS.md": "\nUser-owned rules\n\n", - "CLAUDE.md": "My Claude instructions\n", - ".one/agents/conventions.md": "My existing conventions\n", - } - for rel, content := range files { - path := filepath.Join(ws, filepath.FromSlash(rel)) - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { - t.Fatal(err) - } - } - stdout, stderr, code := runBinaryIn(t, ws, "add", "go-api", "--name", "api", "--yes", "-o", "json") - if code != 0 { - t.Fatalf("add failed: exit=%d stderr=%s", code, stderr) - } - if _, ok := mustParseJSON(t, stdout)["ai_guides"]; ok { - t.Fatal("add still reports automatic agent guide generation") - } - for rel, want := range files { - got, err := os.ReadFile(filepath.Join(ws, filepath.FromSlash(rel))) - if err != nil || string(got) != want { - t.Errorf("existing %s changed: got=%q err=%v", rel, got, err) - } - } - assertNoAgentDocs(t, filepath.Join(ws, "services", "api")) -} diff --git a/packages/cli/tests/e2e/snapshot_e2e_create_test.go b/packages/cli/tests/e2e/snapshot_e2e_create_test.go deleted file mode 100644 index ea941593..00000000 --- a/packages/cli/tests/e2e/snapshot_e2e_create_test.go +++ /dev/null @@ -1,287 +0,0 @@ -package cli_test - -// E2E coverage of `one create`. -// -// Regression targets: -// commit 6c6b492 — `one create [dir] + --name`; positional arg is the dir -// commit 61505b7 — dropped `--overwrite/--ignore`; non-empty target now errors - -import ( - "io/fs" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" -) - -func assertNoAgentDocs(t *testing.T, root string) { - t.Helper() - err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { - if err != nil { - return err - } - if entry.Name() == "AGENTS.md" || entry.Name() == "CLAUDE.md" || - entry.Name() == ".one" { - t.Errorf("unexpected generated agent file or metadata directory: %s", path) - } - return nil - }) - if err != nil { - t.Fatal(err) - } -} - -// expectedScaffoldPaths is the set of files/dirs `one create` must produce -// at the workspace root. Asserting this directly (rather than only via the -// JSON envelope) catches regressions where the envelope is correct but -// scaffold output silently changed. -var expectedScaffoldPaths = []string{ - "apps", - "services", - "packages", - "one.manifest.toml", - "AGENTS.md", - "mise.toml", - ".config/hk.pkl", - ".git/hooks/pre-commit", - ".git/hooks/commit-msg", -} - -func TestSnapshot_E2E_Create_Default(t *testing.T) { - tmp := t.TempDir() - isolateHome(t, tmp) - - target := filepath.Join(tmp, "my-app") - stdout, stderr, code := runBinary(t, "create", target, "-y", "-o", "json") - if code != 0 { - t.Fatalf("expected exit 0, got %d\n stdout: %s\n stderr: %s", code, stdout, stderr) - } - - got := mustParseJSON(t, stdout) - assertSnapshot(t, "create-default.json", got) - - // File-tree shape is part of the contract. - for _, p := range expectedScaffoldPaths { - full := filepath.Join(target, p) - if !fileExists(t, full) { - t.Errorf("expected scaffold path missing: %s", full) - } - } - assertWorkspaceAgentDocs(t, target) - - // Manifest sanity. Schema is the current ManifestVersion. - mf := readManifest(t, target) - if v, _ := mf["version"].(int64); v != int64(workspace.ManifestVersion) { - t.Errorf("manifest version: want %d, got %v", workspace.ManifestVersion, mf["version"]) - } - if subs, _ := mf["projects"].(map[string]any); len(subs) != 0 { - t.Errorf("fresh manifest should have empty projects, got %v", mf["projects"]) - } -} - -func TestSnapshot_E2E_Create_NameOverride(t *testing.T) { - tmp := t.TempDir() - isolateHome(t, tmp) - - target := filepath.Join(tmp, "services", "billing") - stdout, stderr, code := runBinary(t, "create", target, "--name", "custom-name", "-y", "-o", "json") - if code != 0 { - t.Fatalf("expected exit 0, got %d\n stderr: %s", code, stderr) - } - - got := mustParseJSON(t, stdout) - if got["project_name"] != "custom-name" { - t.Errorf("project_name override: want %q, got %v", "custom-name", got["project_name"]) - } - - // The language-neutral workspace keeps its name in the manifest. - mf := readManifest(t, target) - if mf["workspace"].(map[string]any)["name"] != "custom-name" { - t.Fatal("workspace name override was lost") - } - if fileExists(t, filepath.Join(target, "package.json")) { - t.Fatal("empty workspace unexpectedly initialized Node") - } - -} - -func TestSnapshot_E2E_Create_NonEmptyTargetFails(t *testing.T) { - tmp := t.TempDir() - isolateHome(t, tmp) - - target := filepath.Join(tmp, "occupied") - if err := os.MkdirAll(target, 0o755); err != nil { - t.Fatalf("mkdir: %v", err) - } - if err := os.WriteFile(filepath.Join(target, "preexisting.txt"), []byte("hi"), 0o644); err != nil { - t.Fatalf("write preexisting: %v", err) - } - - _, stderr, code := runBinary(t, "create", target, "-y", "-o", "json") - if code == 0 { - t.Fatalf("expected non-zero exit, got 0\n stderr: %s", stderr) - } - - envelope := firstJSONLine(stderr) - if envelope == "" { - t.Fatalf("expected JSON error envelope on stderr, got: %q", stderr) - } - got := mustParseJSON(t, envelope) - errMap, ok := got["error"].(map[string]any) - if !ok { - t.Fatalf("envelope missing error object: %s", envelope) - } - if errMap["code"] != "EXISTING_TARGET_NOT_EMPTY" { - t.Errorf("expected error.code=EXISTING_TARGET_NOT_EMPTY (regression for commit 61505b7), got %v", errMap["code"]) - } - assertSnapshot(t, "create-non-empty-error.json", got) -} - -// TestSnapshot_E2E_Create_NestedInsideWorkspace_Refused locks the guard: -// `one create` must refuse to plant a workspace inside a directory that -// already has a one.manifest.toml anywhere in its ancestry. Without this, -// two manifests in the same tree silently break env/add discovery. -func TestSnapshot_E2E_Create_NestedInsideWorkspace_Refused(t *testing.T) { - tmp := t.TempDir() - isolateHome(t, tmp) - outer := bootstrapWorkspace(t, tmp, "outer") - - // Try to create a NEW workspace path that lives inside `outer`. The - // target dir doesn't exist yet, so this isn't EXISTING_TARGET_NOT_EMPTY — - // only the new nesting guard would catch it. - nested := filepath.Join(outer, "packages", "inner") - _, stderr, code := runBinary(t, "create", nested, "-y", "-o", "json") - if code == 0 { - t.Fatalf("expected non-zero exit when creating inside an existing workspace, got 0\n stderr: %s", stderr) - } - envelope := firstJSONLine(stderr) - if envelope == "" { - t.Fatalf("expected JSON error envelope on stderr, got: %q", stderr) - } - got := mustParseJSON(t, envelope) - errMap, ok := got["error"].(map[string]any) - if !ok { - t.Fatalf("envelope missing error object: %s", envelope) - } - if errMap["code"] != "WORKSPACE_NESTED_FORBIDDEN" { - t.Errorf("expected error.code=WORKSPACE_NESTED_FORBIDDEN, got %v", errMap["code"]) - } - - // And the variant: target IS an existing workspace (re-init attempt). - _, stderr, code = runBinary(t, "create", outer, "-y", "-o", "json") - if code == 0 { - t.Fatalf("expected non-zero exit when target is already a workspace, got 0\n stderr: %s", stderr) - } - envelope = firstJSONLine(stderr) - got = mustParseJSON(t, envelope) - errMap = got["error"].(map[string]any) - // The non-empty target check fires first here (workspace dir has files); - // either guard is acceptable as long as it's a refusal, not a silent - // re-scaffold. The contract that matters: code != 0 + structured error. - if errMap["code"] == "" { - t.Errorf("expected a refusal envelope, got empty code") - } -} - -// TestSnapshot_E2E_Create_DefaultEnablesUniversalSet verifies the -// post-trim defaults policy: `one create -y` enables env/dotenv and local -// development, but does not enable CI. deploy / container are also deferred. -func TestSnapshot_E2E_Create_DefaultEnablesUniversalSet(t *testing.T) { - tmp := t.TempDir() - isolateHome(t, tmp) - - target := filepath.Join(tmp, "ws") - stdout, stderr, code := runBinary(t, "create", target, "-y", "-o", "json") - if code != 0 { - t.Fatalf("expected exit 0, got %d\n stdout: %s\n stderr: %s", code, stdout, stderr) - } - got := mustParseJSON(t, stdout) - if got["secrets_backend"] != "infisical" { - t.Errorf("secrets_backend: want infisical, got %v", got["secrets_backend"]) - } - if _, exists := got["ci_enabled"]; exists { - t.Error("creation still exposes the removed CI feature") - } - if got["dev_enabled"] != true { - t.Errorf("dev_enabled: want true, got %v", got["dev_enabled"]) - } - - // Fresh workspaces have no remote binding or command configuration. - mf := readManifest(t, target) - if _, has := mf["plugins"]; has { - t.Errorf("manifest should not carry legacy plugins map, got %v", mf["plugins"]) - } - if _, exists := mf["env"]; exists { - t.Fatal("creation unexpectedly enabled remote injection before binding") - } - if _, exists := mf["domains"]; exists { - t.Fatal("creation wrote retired domains") - } - for _, removed := range []string{"ci", "dev"} { - if _, has := mf[removed]; has { - t.Errorf("manifest must not carry top-level %q, got %v", removed, mf[removed]) - } - } -} - -func assertWorkspaceAgentDocs(t *testing.T, root string) { - t.Helper() - body, err := os.ReadFile(filepath.Join(root, "AGENTS.md")) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(body), "one run") || !strings.Contains(string(body), "one exec") { - t.Fatalf("workspace guidance does not explain task execution and environment injection: %s", body) - } - for _, entry := range []string{"apps", "services", "packages"} { - assertNoAgentDocs(t, filepath.Join(root, entry)) - } - for _, entry := range []string{"CLAUDE.md", ".one", ".agents"} { - if _, err := os.Stat(filepath.Join(root, entry)); !os.IsNotExist(err) { - t.Fatalf("unexpected generated %s: %v", entry, err) - } - } -} - -func TestCreateInCurrentEmptyGitRepository(t *testing.T) { - tmp := t.TempDir() - isolateHome(t, tmp) - target := filepath.Join(tmp, "existing-repo") - if err := os.Mkdir(target, 0755); err != nil { - t.Fatal(err) - } - for _, args := range [][]string{{"init", "-b", "topic"}, {"remote", "add", "origin", "https://example.invalid/team/existing.git"}} { - cmd := exec.Command("git", args...) - cmd.Dir = target - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("git: %v %s", err, out) - } - } - before, _ := os.ReadFile(filepath.Join(target, ".git", "config")) - stdout, stderr, code := runBinaryIn(t, target, "create", ".", "--yes", "-o", "json") - if code != 0 { - t.Fatalf("create: %d %s %s", code, stdout, stderr) - } - result := mustParseJSON(t, stdout) - if result["project_name"] != "existing-repo" || result["created_in_place"] != true { - t.Fatalf("result=%v", result) - } - after, _ := os.ReadFile(filepath.Join(target, ".git", "config")) - if string(before) != string(after) { - t.Fatal("Git config changed") - } - manifestBefore, _ := os.ReadFile(filepath.Join(target, "one.manifest.toml")) - for _, args := range [][]string{{"env", "list", "-o", "json"}, {"env", "unset", "TOKEN", "-o", "json"}} { - _, stderr, code = runBinaryIn(t, target, args...) - if code == 0 || !strings.Contains(stderr, "INFISICAL_NOT_CONFIGURED") { - t.Fatalf("%v: %d %s", args, code, stderr) - } - } - manifestAfter, _ := os.ReadFile(filepath.Join(target, "one.manifest.toml")) - if string(manifestBefore) != string(manifestAfter) { - t.Fatal("read/delete changed binding") - } -} diff --git a/packages/cli/tests/e2e/snapshot_e2e_dev_test.go b/packages/cli/tests/e2e/snapshot_e2e_dev_test.go deleted file mode 100644 index bd9d4a7a..00000000 --- a/packages/cli/tests/e2e/snapshot_e2e_dev_test.go +++ /dev/null @@ -1,127 +0,0 @@ -package cli_test - -// Development uses the same mise graph and result protocol as other tasks. - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/pelletier/go-toml/v2" -) - -func TestSnapshot_E2E_DevPositionalSelectorUnknown(t *testing.T) { - tmp := t.TempDir() - isolateHome(t, tmp) - ws := bootstrapWorkspace(t, tmp, "ws") - for _, sub := range []string{"start", "path"} { - _, stderr, code := runBinaryIn(t, ws, "dev", sub, "-o", "json") - if code == 0 { - t.Fatalf("expected unknown positional project %q to fail", sub) - } - got := mustParseJSON(t, firstJSONLine(stderr)) - if !strings.Contains(got["error"].(map[string]any)["message"].(string), "-p") { - t.Fatalf("expected project flag guidance for `one dev %s`, got: %s", sub, stderr) - } - } -} - -func TestSnapshot_E2E_DevProjectSelectorUnknown(t *testing.T) { - tmp := t.TempDir() - isolateHome(t, tmp) - ws := bootstrapWorkspace(t, tmp, "ws") - - _, stderr, code := runBinaryIn(t, ws, "add", "go-api", "--name", "api", "-y", "-o", "json") - if code != 0 { - t.Fatalf("add api failed: exit %d\n stderr: %s", code, stderr) - } - - t.Setenv("ONE_MISE_BINARY", filepath.Join(ws, "missing-mise")) - _, stderr, code = runBinaryIn(t, ws, "dev", "-p", "nonexistent", "-o", "json") - if code == 0 { - t.Fatal("expected `one dev -p nonexistent` to fail") - } - got := mustParseJSON(t, firstJSONLine(stderr)) - errMap := got["error"].(map[string]any) - if errMap["code"] != "SUBPROJECT_NOT_FOUND" { - t.Fatalf("expected SUBPROJECT_NOT_FOUND, got %v", got) - } - ctx := errMap["context"].(map[string]any) - available, _ := ctx["available_projects"].([]any) - if len(available) == 0 { - t.Fatalf("envelope should list available_projects, got %v", errMap) - } -} - -func TestSnapshot_E2E_DevFromMise(t *testing.T) { - ws := buildFixture(t, true) - overrideDevCommand(t, ws, "lib", "echo manifest-development") - stdout, stderr, code := runBinaryIn(t, ws, "dev", "-p", "lib", "-o", "json") - if code != 0 || !strings.Contains(stderr, "manifest-development") || !json.Valid([]byte(stdout)) { - t.Fatalf("exit=%d stdout=%s stderr=%s", code, stdout, stderr) - } - result := mustParseJSON(t, stdout) - if result["schema"] != "one-cli/task-result/v1" || result["status"] != "succeeded" { - t.Fatal(result) - } -} - -// Adding a project must never persist task commands in its manifest. -func TestSnapshot_E2E_ManifestDoesNotStoreDevCommand(t *testing.T) { - tmp := t.TempDir() - isolateHome(t, tmp) - ws := bootstrapWorkspace(t, tmp, "ws") - - if _, stderr, code := runBinaryIn(t, ws, "add", "go-api", "--name", "api", "-y", "-o", "json"); code != 0 { - t.Fatalf("add api failed: %d\n stderr: %s", code, stderr) - } - if got := readDevCommandFromManifest(t, ws, "api"); got != "" { - t.Fatalf("one add persisted the retired dev command") - } - - // Procfile.dev must NOT have been written. - if _, err := os.Stat(filepath.Join(ws, "Procfile.dev")); !os.IsNotExist(err) { - t.Errorf("Procfile.dev should no longer be written, but stat returned err=%v", err) - } -} - -// overrideDevCommand defines a native mise task with a deterministic child. -func overrideDevCommand(t *testing.T, root, projectName, command string) { - t.Helper() - raw, err := os.ReadFile(filepath.Join(root, "one.manifest.toml")) - if err != nil { - t.Fatal(err) - } - var manifest struct { - Projects map[string]struct { - Path string `toml:"path"` - } `toml:"projects"` - } - if err = toml.Unmarshal(raw, &manifest); err != nil { - t.Fatal(err) - } - if project, ok := manifest.Projects[projectName]; ok { - raw, err = toml.Marshal(map[string]any{"tasks": map[string]any{projectName + ":dev": map[string]any{"dir": project.Path, "run": command, "run_windows": command, "raw_args": true}}}) - if err != nil { - t.Fatal(err) - } - appendRootTaskConfig(t, root, string(raw)) - return - } - t.Fatal("project missing") -} - -func readDevCommandFromManifest(t *testing.T, workspaceRoot, projectName string) string { - t.Helper() - m := readManifest(t, workspaceRoot) - projects, _ := m["projects"].(map[string]any) - p, _ := projects[projectName].(map[string]any) - if p == nil { - t.Fatalf("project %s missing", projectName) - } - dev, _ := p["dev"].(map[string]any) - cmd, _ := dev["command"].(string) - return cmd -} diff --git a/packages/cli/tests/e2e/snapshot_e2e_env_test.go b/packages/cli/tests/e2e/snapshot_e2e_env_test.go deleted file mode 100644 index e23da108..00000000 --- a/packages/cli/tests/e2e/snapshot_e2e_env_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package cli_test - -// E2E coverage for the CLI environment-management surface. - -import ( - "strings" - "testing" -) - -func TestSnapshot_E2E_Env_HelpListsSubcommands(t *testing.T) { - stdout, _, code := runBinary(t, "env", "--help") - if code != 0 { - t.Fatalf("expected exit 0, got %d", code) - } - // Each Available Command must show up in --help. Drift here means - // somebody renamed or removed a subcommand without updating callers. - for _, sub := range []string{"set", "unset", "list"} { - if !strings.Contains(stdout, sub) { - t.Errorf("`one env --help` does not mention subcommand %q", sub) - } - } -} - -func TestSnapshot_E2E_Env_RemovedCommands(t *testing.T) { - for _, command := range []string{"get", "pull", "switch"} { - _, _, code := runBinary(t, "env", command, "-o", "json") - if code == 0 { - t.Fatalf("removed command %s still accepted", command) - } - } -} - -func TestE2E_PlaintextReadUnavailableInEveryOutputMode(t *testing.T) { - root := t.TempDir() - isolateHome(t, root) - for _, locale := range []string{"en_US.UTF-8", "zh_CN.UTF-8"} { - t.Setenv("LC_ALL", locale) - for _, format := range []string{"text", "json", "yaml"} { - for _, scope := range [][]string{nil, {"--global", "--path", "/"}} { - for _, reveal := range [][]string{nil, {"--reveal"}} { - args := append([]string{"env", "get", "TOKEN", "--env", "dev", "-o", format}, scope...) - args = append(args, reveal...) - _, _, code := runBinaryIn(t, root, args...) - if code == 0 { - t.Fatalf("plaintext read still accepted: %v", args) - } - } - } - } - } -} - -func TestE2E_MalformedSecretAssignmentDoesNotEchoValues(t *testing.T) { - root := t.TempDir() - isolateHome(t, root) - for _, locale := range []string{"en_US.UTF-8", "zh_CN.UTF-8"} { - t.Setenv("LC_ALL", locale) - for _, format := range []string{"text", "json", "yaml"} { - for _, scope := range [][]string{nil, {"--global", "--path", "/"}} { - for _, input := range [][]string{{"TOKEN=private-assignment-value", "second-private-value"}, {"=private-assignment-value"}} { - args := append([]string{"env", "set", "--env", "dev", "-o", format}, scope...) - args = append(args, input...) - out, errOut, code := runBinaryIn(t, root, args...) - if code == 0 || strings.Contains(out+errOut, "private-assignment-value") || strings.Contains(out+errOut, "second-private-value") { - t.Fatalf("unsafe error output: %s %s", out, errOut) - } - } - } - } - } -} diff --git a/packages/cli/tests/e2e/snapshot_e2e_run_test.go b/packages/cli/tests/e2e/snapshot_e2e_run_test.go deleted file mode 100644 index 4c587c73..00000000 --- a/packages/cli/tests/e2e/snapshot_e2e_run_test.go +++ /dev/null @@ -1,250 +0,0 @@ -package cli_test - -// E2E coverage of `one exec`. -// -// `one exec` is an exec-passthrough: it loads the resolved subproject's -// .env into the child process environment and execs the requested command. -// The contract these tests pin down: -// - no args: prints help (Long usage block) and exits 0 -// - happy path: cd into a subproject with a .env, exec a command, -// stdout passes through, exit code passes through, .env vars are -// visible inside the child -// - --override flips the merge order so .env beats inherited shell vars -// - missing .env → runs leniently with zero injected vars (not an error) -// - unknown executable → RUN_COMMAND_NOT_FOUND envelope -// - -p / --project resolves a subproject by manifest name or relativeDir -// from any cwd -// -// We don't snapshot stdout — exec passthrough is byte-exact and any drift -// would be a real bug, not a fixture-update event. - -import ( - "fmt" - "os" - "path/filepath" - "runtime" - "strings" - "testing" -) - -// addSubprojectWithDotenv bootstraps a workspace, scaffolds an go-api -// subproject under services/, drops a deterministic .env into it, -// and returns (workspaceRoot, subprojectDir). -func addSubprojectWithDotenv(t *testing.T, name string, dotenv string) (string, string) { - t.Helper() - tmp := t.TempDir() - isolateHome(t, tmp) - ws := bootstrapWorkspace(t, tmp, "ws") - - stdout, stderr, code := runBinaryIn(t, ws, "add", "go-api", "--name", name, "-y", "-o", "json") - if code != 0 { - t.Fatalf("add %s failed: exit %d\n stdout: %s\n stderr: %s", name, code, stdout, stderr) - } - subDir := filepath.Join(ws, "services", name) - if err := os.WriteFile(filepath.Join(subDir, ".env"), []byte(dotenv), 0o600); err != nil { - t.Fatalf("write .env: %v", err) - } - return ws, subDir -} - -func TestSnapshot_E2E_Run_NoArgs_PrintsHelp(t *testing.T) { - // `one exec` 不带任何位置参数时应当像父命令一样打印 help 并 exit 0, - // 而不是返回 cobra 的 "requires at least 1 arg(s)" JSON 错误。 - tmp := t.TempDir() - isolateHome(t, tmp) - - t.Setenv("LC_ALL", "en_US.UTF-8") - stdout, stderr, code := runBinaryIn(t, tmp, "exec") - if code != 0 { - t.Fatalf("expected exit code 0, got %d\n stdout: %s\n stderr: %s", code, stdout, stderr) - } - // Long help 的稳定锚点:第一行短描述里的关键短语。换文案时只需调一次。 - if !strings.Contains(stdout, "Load environment variables") { - t.Errorf("expected help text on stdout (containing %q), got: %q", "Load environment variables", stdout) - } - if !strings.Contains(stdout, "USAGE") { - t.Errorf("expected cobra Usage block on stdout, got: %q", stdout) - } -} - -func TestSnapshot_E2E_Run_DotenvIsIgnored(t *testing.T) { - t.Setenv("RUN_TEST_KEY", "from-shell") - _, subDir := addSubprojectWithDotenv(t, "user-api", - "RUN_TEST_KEY=hello-from-dotenv\n") - - // Use sh -c so we can both observe stdout and read $RUN_TEST_KEY in - // one shot. printenv is portable; -- separator pins the contract for - // users who type it explicitly. - args := append([]string{"exec", "--"}, environmentEchoCommand("RUN_TEST_KEY")...) - stdout, stderr, code := runBinaryIn(t, subDir, args...) - if code != 0 { - t.Fatalf("run failed: exit %d\n stdout: %q\n stderr: %q", code, stdout, stderr) - } - if got := strings.TrimSpace(stdout); got != "from-shell" { - t.Errorf("stdout: want %q, got %q", "from-shell", got) - } -} - -func TestSnapshot_E2E_Run_PositionalProjectSelector(t *testing.T) { - t.Setenv("RUN_TEST_KEY", "from-shell") - ws, _ := addSubprojectWithDotenv(t, "auth", - "RUN_TEST_KEY=hello-from-dotenv\n") - - args := append([]string{"exec", "auth", "--"}, environmentEchoCommand("RUN_TEST_KEY")...) - stdout, stderr, code := runBinaryIn(t, ws, args...) - if code != 0 { - t.Fatalf("positional project run failed: exit %d\n stdout: %q\n stderr: %q", code, stdout, stderr) - } - if got := strings.TrimSpace(stdout); got != "from-shell" { - t.Errorf("stdout: want %q, got %q", "from-shell", got) - } -} - -func TestSnapshot_E2E_Run_ExitCodePassthrough(t *testing.T) { - _, subDir := addSubprojectWithDotenv(t, "billing", "K=v\n") - - args := append([]string{"exec", "--"}, exitWithCodeCommand(42)...) - _, _, code := runBinaryIn(t, subDir, args...) - if code != 42 { - t.Errorf("expected child exit code 42 to passthrough, got %d", code) - } -} - -func TestSnapshot_E2E_Run_MissingDotenv_RunsLeniently(t *testing.T) { - tmp := t.TempDir() - isolateHome(t, tmp) - ws := bootstrapWorkspace(t, tmp, "ws") - - // Add a subproject but DO NOT write a .env. `one exec` should now - // continue with zero injected variables instead of erroring — `.env` - // is gitignored and optional, so the first-run experience must not - // require it. - stdout, stderr, code := runBinaryIn(t, ws, "add", "go-api", "--name", "no-env", "-y", "-o", "json") - if code != 0 { - t.Fatalf("add failed: exit %d\n stdout: %s\n stderr: %s", code, stdout, stderr) - } - subDir := filepath.Join(ws, "services", "no-env") - - stdout, stderr, code = runBinaryIn(t, subDir, "exec", "--", "echo", "hi") - if code != 0 { - t.Fatalf("expected exit 0 with no .env, got %d\n stdout: %s\n stderr: %s", code, stdout, stderr) - } - if !strings.Contains(stdout, "hi") { - t.Errorf("stdout: want %q, got %q", "hi", stdout) - } -} - -func TestSnapshot_E2E_Run_MissingSeparator_ReturnsStructuredError(t *testing.T) { - ws, _ := addSubprojectWithDotenv(t, "auth", "K=v\n") - - _, stderr, code := runBinaryIn(t, ws, "exec", "-o", "json", "auth", "echo", "hi") - if code == 0 { - t.Fatal("expected missing -- separator to fail") - } - got := mustParseJSON(t, firstJSONLine(stderr)) - errMap := got["error"].(map[string]any) - if errMap["code"] != "RUN_USAGE_INVALID" { - t.Fatalf("expected RUN_USAGE_INVALID, got %v", got) - } - ctx := errMap["context"].(map[string]any) - if ctx["reason"] != "separator-required" { - t.Fatalf("expected separator-required context, got %v", ctx) - } -} - -func TestSnapshot_E2E_Run_SelectorConflict_ReturnsStructuredError(t *testing.T) { - ws, _ := addSubprojectWithDotenv(t, "auth", "K=v\n") - - _, stderr, code := runBinaryIn(t, ws, "exec", "-o", "json", "auth", "-p", "billing", "--", "echo", "hi") - if code == 0 { - t.Fatal("expected conflicting project selectors to fail") - } - got := mustParseJSON(t, firstJSONLine(stderr)) - errMap := got["error"].(map[string]any) - if errMap["code"] != "RUN_USAGE_INVALID" { - t.Fatalf("expected RUN_USAGE_INVALID, got %v", got) - } - ctx := errMap["context"].(map[string]any) - if ctx["reason"] != "selector-conflict" { - t.Fatalf("expected selector-conflict context, got %v", ctx) - } - if ctx["positional_project"] != "auth" || ctx["flag_project"] != "billing" { - t.Fatalf("expected conflicting selectors in context, got %v", ctx) - } -} - -func TestSnapshot_E2E_Run_UnknownCommand_ReturnsStructuredError(t *testing.T) { - _, subDir := addSubprojectWithDotenv(t, "billing", "K=v\n") - - // A name that almost certainly isn't on $PATH. If a future CI image - // somehow ships this binary, the test will yell loudly — the random - // suffix is deliberately unguessable. - _, stderr, code := runBinaryIn(t, subDir, "exec", "-o", "json", "--", - "definitely-not-a-real-binary-xZQ7p9") - if code == 0 { - t.Fatalf("expected non-zero exit for unknown command, got 0\n stderr: %s", stderr) - } - envelope := firstJSONLine(stderr) - if envelope == "" { - t.Fatalf("expected JSON error envelope on stderr, got: %q", stderr) - } - got := mustParseJSON(t, envelope) - errMap, ok := got["error"].(map[string]any) - if !ok { - t.Fatalf("envelope missing error object: %s", envelope) - } - if errMap["code"] != "RUN_COMMAND_NOT_FOUND" { - t.Errorf("error.code: want RUN_COMMAND_NOT_FOUND, got %v", errMap["code"]) - } -} - -func TestSnapshot_E2E_Run_PreservesShellWithoutBinding(t *testing.T) { - ws, subDir := addSubprojectWithDotenv(t, "auth", - "OVERRIDE_KEY=value-from-dotenv\n") - - // Default behaviour (v0.8+): injected secrets always overwrite shell vars. - t.Setenv("OVERRIDE_KEY", "value-from-shell") - - args := append([]string{"exec", "--"}, environmentEchoCommand("OVERRIDE_KEY")...) - stdout, stderr, code := runBinaryIn(t, subDir, args...) - if code != 0 { - t.Fatalf("default run failed: exit %d\n stderr: %s", code, stderr) - } - if got := strings.TrimSpace(stdout); got != "value-from-shell" { - t.Errorf("default merge: want inherited shell value, got %q", got) - } - - // Sanity: -p resolves the same subproject from workspace root, both by - // relative path and by manifest name (v0.7+ name-based selection). - args = append([]string{"exec", "-p", "services/auth", "--"}, environmentEchoCommand("OVERRIDE_KEY")...) - stdout, stderr, code = runBinaryIn(t, ws, args...) - if code != 0 { - t.Fatalf("-p path run failed: exit %d\n stderr: %s", code, stderr) - } - if got := strings.TrimSpace(stdout); got != "value-from-shell" { - t.Errorf("-p path resolution: want inherited shell value, got %q", got) - } - - args = append([]string{"exec", "-p", "auth", "--"}, environmentEchoCommand("OVERRIDE_KEY")...) - stdout, stderr, code = runBinaryIn(t, ws, args...) - if code != 0 { - t.Fatalf("-p name run failed: exit %d\n stderr: %s", code, stderr) - } - if got := strings.TrimSpace(stdout); got != "value-from-shell" { - t.Errorf("-p name resolution: want inherited shell value, got %q", got) - } -} - -func environmentEchoCommand(name string) []string { - if runtime.GOOS == "windows" { - return []string{"cmd.exe", "/d", "/s", "/c", "echo %" + name + "%"} - } - return []string{"sh", "-c", "printenv " + name} -} - -func exitWithCodeCommand(code int) []string { - if runtime.GOOS == "windows" { - return []string{"cmd.exe", "/d", "/s", "/c", "exit /b " + fmt.Sprint(code)} - } - return []string{"sh", "-c", "exit " + fmt.Sprint(code)} -} diff --git a/packages/cli/tests/e2e/snapshot_e2e_serve_test.go b/packages/cli/tests/e2e/snapshot_e2e_serve_test.go deleted file mode 100644 index b447038a..00000000 --- a/packages/cli/tests/e2e/snapshot_e2e_serve_test.go +++ /dev/null @@ -1,149 +0,0 @@ -package cli_test - -// E2E coverage of `one serve`. Locks the JSON envelope shape, probes the -// loopback API without authentication, and confirms graceful SIGINT shutdown. -// -// `serve` is the only top-level command that blocks indefinitely — every -// other snapshot test exec's the binary and reads stdout. We adapt by -// streaming stdout into a buffer until the envelope appears, then probing -// the live HTTP server, then SIGINT-ing the process. - -import ( - "bytes" - "net/http" - "net/url" - "os" - "os/exec" - "runtime" - "sync" - "testing" - "time" -) - -func TestSnapshot_E2E_Serve_StartupEnvelope(t *testing.T) { - tmp := t.TempDir() - isolateHome(t, tmp) - - cmd := exec.Command(binaryPath(t), "serve", "--port", "0", "--open=false", "-o", "json") - cmd.Env = append(os.Environ(), "HOME="+tmp, "XDG_CONFIG_HOME=") - - var stdout, stderr safeBuf - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Start(); err != nil { - t.Fatalf("start: %v", err) - } - t.Cleanup(func() { - // Belt-and-suspenders: if a sub-test fails before sending SIGINT, - // kill the process so the test binary doesn't hang. - _ = cmd.Process.Kill() - _ = cmd.Wait() - }) - - // Wait up to 5s for the envelope to land on stdout. The server prints - // it immediately after binding. - envelope := waitForEnvelope(t, &stdout, 5*time.Second, stderr.String) - if envelope["schema"] != "one-cli/serve/v2" { - t.Errorf("schema: got %v", envelope["schema"]) - } - if envelope["status"] != "listening" { - t.Errorf("status: got %v", envelope["status"]) - } - if envelope["host"] != "127.0.0.1" { - t.Errorf("host: got %v", envelope["host"]) - } - rawPort, ok := envelope["port"].(float64) - if !ok || rawPort < 1 { - t.Errorf("port: got %v (%T)", envelope["port"], envelope["port"]) - } - rawURL, _ := envelope["url"].(string) - if rawURL == "" { - t.Fatalf("url empty: %v", envelope) - } - parsedURL, err := url.Parse(rawURL) - if err != nil { - t.Fatalf("parse URL: %v", err) - } - if parsedURL.RawQuery != "" { - t.Fatalf("URL must not contain credentials: %s", rawURL) - } - if _, exists := envelope["token"]; exists { - t.Fatalf("startup envelope still exposes token: %v", envelope) - } - - // Probe /api/global-env/location directly. Empty config should yield a 200 with the - // schema-shaped payload. - probe := rawURL + "api/global-env/location" - res, err := http.Get(probe) - if err != nil { - t.Fatalf("probe: %v", err) - } - res.Body.Close() - if res.StatusCode != 200 { - t.Errorf("probe: want 200, got %d", res.StatusCode) - } - - // Unix can validate graceful SIGINT shutdown directly. Windows does not - // support os.Process.Signal for Ctrl-C, so terminate the test child after - // the live-server contract has been verified. - expectCleanExit := runtime.GOOS != "windows" - var stopErr error - if expectCleanExit { - stopErr = cmd.Process.Signal(os.Interrupt) - } else { - stopErr = cmd.Process.Kill() - } - if stopErr != nil { - t.Fatalf("stop server: %v", stopErr) - } - done := make(chan error, 1) - go func() { done <- cmd.Wait() }() - select { - case err := <-done: - if err != nil && expectCleanExit { - t.Errorf("clean shutdown: got %v\n stderr: %s", err, stderr.String()) - } - case <-time.After(5 * time.Second): - _ = cmd.Process.Kill() - t.Fatalf("server did not exit within 5s after stop request") - } - - assertSnapshot(t, "serve.json", envelope) -} - -// safeBuf is a bytes.Buffer with a mutex so the goroutine running the -// child process can write while the test goroutine reads. Without the -// guard the race detector flags the concurrent access. -type safeBuf struct { - mu sync.Mutex - b bytes.Buffer -} - -func (s *safeBuf) Write(p []byte) (int, error) { - s.mu.Lock() - defer s.mu.Unlock() - return s.b.Write(p) -} - -func (s *safeBuf) String() string { - s.mu.Lock() - defer s.mu.Unlock() - return s.b.String() -} - -// waitForEnvelope polls stdout until firstJSONLine returns a complete -// envelope or the timeout expires. dumpStderr produces the stderr buffer -// for diagnostics on timeout. -func waitForEnvelope(t *testing.T, stdout *safeBuf, timeout time.Duration, dumpStderr func() string) map[string]any { - t.Helper() - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - if line := firstJSONLine(stdout.String()); line != "" { - return mustParseJSON(t, line) - } - time.Sleep(20 * time.Millisecond) - } - t.Fatalf("envelope never appeared\n stdout: %s\n stderr: %s", stdout.String(), dumpStderr()) - return nil -} diff --git a/packages/cli/tests/e2e/snapshot_e2e_test.go b/packages/cli/tests/e2e/snapshot_e2e_test.go deleted file mode 100644 index 16bf9d78..00000000 --- a/packages/cli/tests/e2e/snapshot_e2e_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package cli_test - -// True end-to-end snapshot tests: build the binary, exec it, diff stdout/ -// stderr against testdata/reference/. These complement the in-process tests -// in internal/core/template/list_snapshot_test.go — those test the business -// logic; this file tests the actual CLI surface (cobra, error envelopes, -// stdout/stderr partitioning, exit codes). -// -// Tests are skipped if the binary hasn't been built yet (run `mise run build`). -// CI should run `mise run build && mise run test` so the binary is present. -// Shared helpers live in e2e_helpers_test.go. - -import ( - "reflect" - "regexp" - "testing" -) - -func TestSnapshot_E2E_Version(t *testing.T) { - stdout, _, code := runBinary(t, "--version") - if code != 0 { - t.Fatalf("expected exit 0, got %d", code) - } - // Builds with RELEASE_VERSION print that value. Development builds use a - // non-release sentinel, while `task install` appends the - // current short commit and an optional dirty marker; both are deliberate, - // user-visible formats and must remain snapshot-compatible. - want := regexp.MustCompile(`^` + regexp.QuoteMeta(expectedBuildVersion(t)) + `(?:-local\.[0-9a-f]{7}(?:\.dirty)?)?\n$`) - if !want.MatchString(stdout) { - t.Errorf("stdout mismatch\n want pattern: %s\n got: %q", want, stdout) - } -} - -func TestSnapshot_E2E_TemplatesJSON(t *testing.T) { - stdout, _, code := runBinary(t, "templates", "-o", "json") - if code != 0 { - t.Fatalf("expected exit 0, got %d", code) - } - gotMap := mustParseJSON(t, stdout) - want := loadFixture(t, "templates.json") - if !reflect.DeepEqual(gotMap, want) { - t.Errorf("drift vs testdata/reference/templates.json\n got: %s", pretty(gotMap)) - } -} - -func TestSnapshot_E2E_UnknownCommand(t *testing.T) { - isolateHome(t, t.TempDir()) - t.Setenv("LC_ALL", "en_US.UTF-8") - _, stderr, code := runBinary(t, "__unknown-cmd", "-o", "json") - if code != 1 { - t.Fatalf("expected exit 1, got %d", code) - } - gotMap := mustParseJSON(t, firstJSONLine(stderr)) - want := loadFixture(t, "error-unknown-command.json") - if !reflect.DeepEqual(gotMap, want) { - t.Errorf("drift vs testdata/reference/error-unknown-command.json\n want: %s\n got: %s", - pretty(want), pretty(gotMap)) - } -} diff --git a/packages/cli/tests/e2e/snapshot_e2e_tty_unix_test.go b/packages/cli/tests/e2e/snapshot_e2e_tty_unix_test.go deleted file mode 100644 index 84940137..00000000 --- a/packages/cli/tests/e2e/snapshot_e2e_tty_unix_test.go +++ /dev/null @@ -1,130 +0,0 @@ -//go:build !windows - -package cli_test - -import ( - "bytes" - "io" - "os" - "os/exec" - "path/filepath" - "strings" - "sync" - "testing" - "time" - - "github.com/creack/pty" -) - -func TestE2E_EnvSetHidesValueBeforeScopeCancellation(t *testing.T) { - tmp := t.TempDir() - isolateHome(t, tmp) - ws := bootstrapWorkspace(t, tmp, "demo") - if _, stderr, code := runBinaryIn(t, ws, "add", "react-spa", "--name", "web", "--yes", "-o", "json"); code != 0 { - t.Fatalf("add failed: exit=%d stderr=%s", code, stderr) - } - setWorkspaceBindingFixture(t, ws) - - bin := binaryPath(t) - cmd := exec.Command(bin, "env", "set", "TEST_KEY", "-o", "text") - cmd.Dir = ws - cmd.Env = replaceEnvValues(prependPath(os.Environ(), filepath.Dir(bin)), map[string]string{ - "LC_ALL": "C", - "LANG": "C", - "NO_COLOR": "1", - "TERM": "dumb", - }) - - ptmx, err := pty.Start(cmd) - if err != nil { - t.Fatalf("start command in PTY: %v", err) - } - defer ptmx.Close() - - var output lockedBuffer - readDone := make(chan struct{}) - go func() { - _, _ = io.Copy(&output, ptmx) - close(readDone) - }() - - waitForTTYOutput(t, &output, "Enter the value for TEST_KEY", 5*time.Second) - const secret = "not-for-terminal-scrollback" - if _, err := ptmx.Write([]byte(secret + "\r")); err != nil { - t.Fatalf("enter secret: %v", err) - } - waitForTTYOutput(t, &output, "Where does this variable belong?", 5*time.Second) - if err := cmd.Process.Signal(os.Interrupt); err != nil { - t.Fatalf("cancel scope selection: %v", err) - } - - waitDone := make(chan error, 1) - go func() { waitDone <- cmd.Wait() }() - select { - case err := <-waitDone: - if err != nil { - t.Fatalf("env set failed: %v\noutput:\n%s", err, output.String()) - } - case <-time.After(5 * time.Second): - _ = cmd.Process.Kill() - t.Fatal("env set did not finish after choosing a scope") - } - _ = ptmx.Close() - select { - case <-readDone: - case <-time.After(time.Second): - t.Fatal("PTY reader did not finish after env set exited") - } - - if got := output.String(); strings.Contains(got, secret) { - t.Fatalf("secret value leaked into terminal output:\n%s", got) - } - for _, name := range []string{".env", ".env.dev"} { - if _, err := os.Stat(filepath.Join(ws, name)); !os.IsNotExist(err) { - t.Fatalf("unexpected local variable file %s: %v", name, err) - } - } -} - -type lockedBuffer struct { - mu sync.Mutex - b bytes.Buffer -} - -func (b *lockedBuffer) Write(p []byte) (int, error) { - b.mu.Lock() - defer b.mu.Unlock() - return b.b.Write(p) -} - -func (b *lockedBuffer) String() string { - b.mu.Lock() - defer b.mu.Unlock() - return b.b.String() -} - -func waitForTTYOutput(t *testing.T, output *lockedBuffer, want string, timeout time.Duration) { - t.Helper() - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - if strings.Contains(output.String(), want) { - return - } - time.Sleep(10 * time.Millisecond) - } - t.Fatalf("timed out waiting for %q in PTY output:\n%s", want, output.String()) -} - -func replaceEnvValues(env []string, values map[string]string) []string { - result := make([]string, 0, len(env)+len(values)) - for _, kv := range env { - key, _, _ := strings.Cut(kv, "=") - if _, replace := values[key]; !replace { - result = append(result, kv) - } - } - for key, value := range values { - result = append(result, key+"="+value) - } - return result -} diff --git a/packages/cli/tests/e2e/snapshot_e2e_ux_test.go b/packages/cli/tests/e2e/snapshot_e2e_ux_test.go deleted file mode 100644 index 64ff7fbb..00000000 --- a/packages/cli/tests/e2e/snapshot_e2e_ux_test.go +++ /dev/null @@ -1,228 +0,0 @@ -package cli_test - -import ( - "os" - "path/filepath" - "reflect" - "strings" - "testing" - - "gopkg.in/yaml.v3" -) - -func TestSnapshot_E2E_CreateDailyText(t *testing.T) { - tmp := t.TempDir() - isolateHome(t, tmp) - t.Setenv("LC_ALL", "zh_CN.UTF-8") - target := filepath.Join(tmp, "demo") - - stdout, stderr, code := runBinaryIn(t, tmp, "create", target, "--yes", "-o", "text") - if code != 0 { - t.Fatalf("create text flow failed: exit=%d stderr=%q", code, stderr) - } - want := "✓ 工作区已创建:demo\n 位置:~/demo\n 环境变量来源:infisical\n 本地开发:one dev\n\n下一步:\n cd demo\n one add\n" - if stdout != want { - t.Fatalf("unexpected create success text:\n--- want\n%s--- got\n%s", want, stdout) - } - for _, internal := range []string{"profile", "backend", "domain", "manifest selector"} { - if strings.Contains(strings.ToLower(stdout), internal) { - t.Errorf("daily create output exposed internal term %q: %s", internal, stdout) - } - } - - _, errorText, code := runBinaryIn(t, tmp, "create", "--yes", "-o", "text") - if code == 0 { - t.Fatal("non-interactive create without a directory should fail") - } - for _, want := range []string{"✗ 请指定要创建的目录", "错误代码:PROJECT_NAME_REQUIRED", "下一步:", "one create "} { - if !strings.Contains(errorText, want) { - t.Errorf("localized error missing %q: %q", want, errorText) - } - } -} - -func TestSnapshot_E2E_HelpDailyAndCompleteCatalogues(t *testing.T) { - tmp := t.TempDir() - isolateHome(t, tmp) - - for _, locale := range []string{"en_US.UTF-8", "zh_CN.UTF-8"} { - t.Run(locale, func(t *testing.T) { - t.Setenv("LC_ALL", locale) - daily, stderr, code := runBinaryIn(t, tmp, "--help") - if code != 0 || stderr != "" { - t.Fatalf("one --help failed: exit=%d stderr=%q", code, stderr) - } - for _, command := range []string{"create", "add", "env", "login", "run", "exec"} { - if !strings.Contains(daily, "\n "+command+" ") { - t.Errorf("daily help missing %q:\n%s", command, daily) - } - } - for _, command := range []string{"templates", "serve"} { - if strings.Contains(daily, "\n "+command+" ") { - t.Errorf("daily help should not advertise advanced command %q:\n%s", command, daily) - } - } - - all, stderr, code := runBinaryIn(t, tmp, "help", "--all") - if code != 0 || stderr != "" { - t.Fatalf("one help --all failed: exit=%d stderr=%q", code, stderr) - } - for _, command := range []string{"create", "add", "env", "login", "logout", "whoami", "run", "exec", "templates", "serve", "init", "locale", "mise", "hk", "skills"} { - if !strings.Contains(all, "\n "+command+" ") { - t.Errorf("complete help missing %q:\n%s", command, all) - } - } - for _, command := range []string{"dev", "build", "test", "lint", "ci"} { - for _, help := range []string{daily, all} { - if strings.Contains(help, "\n "+command+" ") { - t.Errorf("help advertises task or removed command %q as built-in:\n%s", command, help) - } - } - } - }) - } -} - -func TestSnapshot_E2E_WorkspaceOverviewAndDeferredDeployment(t *testing.T) { - tmp := t.TempDir() - isolateHome(t, tmp) - ws := bootstrapWorkspace(t, tmp, "demo") - - stdout, stderr, code := runBinaryIn(t, ws, "-o", "json") - if code != 0 || stderr != "" { - t.Fatalf("bare one failed: exit=%d stderr=%q", code, stderr) - } - empty := mustParseJSON(t, stdout) - if empty["schema"] != "one-cli/workspace-summary/v1" || empty["next_command"] != "one add" { - t.Fatalf("unexpected empty workspace summary: %v", empty) - } - - if _, stderr, code = runBinaryIn(t, ws, "add", "react-spa", "--name", "web", "--yes", "-o", "json"); code != 0 { - t.Fatalf("add failed: exit=%d stderr=%s", code, stderr) - } - manifestBefore, err := os.ReadFile(filepath.Join(ws, "one.manifest.toml")) - if err != nil { - t.Fatal(err) - } - m := readManifest(t, ws) - projects := m["projects"].(map[string]any) - domains, _ := projects["web"].(map[string]any)["domains"].(map[string]any) - if _, exists := domains["deploy"]; exists { - t.Fatalf("ordinary add configured deployment: %v", domains["deploy"]) - } - if _, exists := domains["container"]; exists { - t.Fatalf("ordinary add configured image build: %v", domains["container"]) - } - - stdout, stderr, code = runBinaryIn(t, ws, "-o", "json") - if code != 0 || stderr != "" { - t.Fatalf("workspace summary after add failed: exit=%d stderr=%q", code, stderr) - } - summary := mustParseJSON(t, stdout) - project := summary["projects"].([]any)[0].(map[string]any) - if project["deployment_configured"] != nil || summary["next_command"] != "one run dev -p web" { - t.Fatalf("unexpected project summary: %v", summary) - } - - savedPath := os.Getenv("PATH") - t.Setenv("PATH", t.TempDir()) - _, stderr, code = runBinaryIn(t, ws, "exec", "-p", "web", "-o", "json", "--", "pnpm", "dev") - t.Setenv("PATH", savedPath) - if code == 0 { - t.Fatal("dev should report that the native package manager is unavailable") - } - devErr := mustParseJSON(t, firstJSONLine(stderr)) - if devErr["error"].(map[string]any)["code"] != "RUN_COMMAND_NOT_FOUND" { - t.Fatalf("unexpected missing-tool error: %v", devErr) - } - - _, stderr, code = runBinaryIn(t, ws, "deploy", "--dry-run", "-o", "json") - if code == 0 { - t.Fatal("removed deploy command should fail") - } - deployErr := mustParseJSON(t, firstJSONLine(stderr)) - if !strings.Contains(deployErr["error"].(map[string]any)["message"].(string), "//:deploy") { - t.Fatalf("unexpected removed-command error: %v", deployErr) - } - manifestAfter, err := os.ReadFile(filepath.Join(ws, "one.manifest.toml")) - if err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(manifestBefore, manifestAfter) { - t.Fatal("failed first-deploy setup modified the workspace") - } -} - -func TestSnapshot_E2E_EnvSummaryRequiresExplicitValue(t *testing.T) { - tmp := t.TempDir() - isolateHome(t, tmp) - ws := bootstrapWorkspace(t, tmp, "demo") - - stdout, stderr, code := runBinaryIn(t, ws, "env", "-o", "json") - if code != 0 || stderr != "" { - t.Fatalf("env summary failed: exit=%d stderr=%q", code, stderr) - } - summary := mustParseJSON(t, stdout) - if summary["schema"] != "one-cli/env-summary/v1" || summary["source"] != "" || summary["default_environment"] != "dev" { - t.Fatalf("unexpected env summary: %v", summary) - } - - _, stderr, code = runBinaryIn(t, ws, "env", "set", "TEST_KEY=private-value", "-o", "json") - if code == 0 { - t.Fatal("unbound workspace should reject variable writes") - } - unboundErr := mustParseJSON(t, firstJSONLine(stderr)) - if unboundErr["error"].(map[string]any)["code"] != "INFISICAL_NOT_CONFIGURED" || !strings.Contains(stderr, "one env bind") || strings.Contains(stderr, "private-value") { - t.Fatalf("unexpected unbound-workspace error: %v", unboundErr) - } - setWorkspaceBindingFixture(t, ws) - - _, stderr, code = runBinaryIn(t, ws, "env", "set", "TEST_KEY", "-o", "json") - if code == 0 { - t.Fatal("non-interactive hidden-value form should require an explicit value") - } - setErr := mustParseJSON(t, firstJSONLine(stderr)) - if setErr["error"].(map[string]any)["code"] != "ENV_SET_VALUE_REQUIRED" { - t.Fatalf("unexpected missing-value error: %v", setErr) - } - -} - -func TestSnapshot_E2E_EnvSummaryYAMLKeepsStableProtocolFields(t *testing.T) { - tmp := t.TempDir() - isolateHome(t, tmp) - ws := bootstrapWorkspace(t, tmp, "demo") - - stdout, stderr, code := runBinaryIn(t, ws, "env", "-o", "yaml") - if code != 0 || stderr != "" { - t.Fatalf("env YAML summary failed: exit=%d stderr=%q", code, stderr) - } - var summary map[string]any - if err := yaml.Unmarshal([]byte(stdout), &summary); err != nil { - t.Fatalf("decode env YAML output: %v\n%s", err, stdout) - } - if summary["schema"] != "one-cli/env-summary/v1" || summary["source"] != "" || summary["default_environment"] != "dev" { - t.Fatalf("unexpected env YAML contract: %v", summary) - } -} - -func TestSnapshot_E2E_RemovedConfigureAndBilingualHelp(t *testing.T) { - tmp := t.TempDir() - isolateHome(t, tmp) - - _, _, code := runBinaryIn(t, tmp, "configure", "-o", "json") - if code == 0 { - t.Fatal("removed configure command still accepted") - } - - t.Setenv("LC_ALL", "zh_CN.UTF-8") - zh, _, code := runBinaryIn(t, tmp, "create", "--help") - if code != 0 || !strings.Contains(zh, "创建工作区") || !strings.Contains(zh, "自动化与高级选项") { - t.Fatalf("Chinese help was not selected:\n%s", zh) - } - t.Setenv("LC_ALL", "en_US.UTF-8") - en, _, code := runBinaryIn(t, tmp, "create", "--help") - if code != 0 || !strings.Contains(en, "Create a workspace") || !strings.Contains(en, "AUTOMATION AND ADVANCED OPTIONS") { - t.Fatalf("English help was not selected:\n%s", en) - } -} diff --git a/packages/cli/tests/e2e/snapshot_help_test.go b/packages/cli/tests/e2e/snapshot_help_test.go deleted file mode 100644 index 30dd05fb..00000000 --- a/packages/cli/tests/e2e/snapshot_help_test.go +++ /dev/null @@ -1,190 +0,0 @@ -package cli_test - -// Help-text drift snapshots. Walks the full cobra command tree, renders -// each command's --help output in-process, and compares against a -// per-command .txt fixture under testdata/reference/help/. Any rename, -// removal, or text edit produces a visible diff in PRs. -// -// Root special-case: `one --help` is intercepted in Execute() before -// cobra runs (see shouldRenderRootHelp) so users see the curated -// rootHelp constant. We snapshot that constant directly as root.txt -// rather than cobra's auto-generated root help — which users never see. -// -// Refresh fixtures with: UPDATE_SNAPSHOTS=1 go test ./tests/e2e/ -run TestHelpSnapshots -// -// Pair with tools/verify-help, which enforces structural invariants -// snapshots cannot catch (rootHelp lists exactly the everyday commands, -// `one help --all` lists every registered command, and every flag named in -// an Example block exists). - -import ( - "bytes" - "os" - "path/filepath" - "runtime" - "sort" - "strings" - "testing" - - "github.com/spf13/cobra" - - cli "github.com/torchstellar-team/one-cli/packages/cli/internal/bootstrap/cli" - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" -) - -// TestMain forces every help snapshot test (and any cmd help renders -// downstream) to run in DefaultLocale for determinism. Production -// code calls i18n.Init from Execute; tests don't go through Execute, -// so they'd otherwise inherit whatever the developer's $LANG happens -// to be — which would make snapshots non-portable. -func TestMain(m *testing.M) { - _ = i18n.Init(i18n.DefaultLocale) - i18n.RefreshTree(cli.RootCmd()) - os.Exit(m.Run()) -} - -// helpSnapshotDir returns the absolute path to testdata/reference/help/. -// Derived from this file's location so `go test` works from any cwd. -func helpSnapshotDir(t *testing.T) string { - t.Helper() - _, file, _, ok := runtime.Caller(0) - if !ok { - t.Fatalf("runtime.Caller failed") - } - // snapshot_help_test.go lives at packages/cli/tests/e2e/ — two dirs up gets - // us to packages/cli/, which contains testdata/. - return filepath.Join(filepath.Dir(file), "..", "..", "testdata", "reference", "help") -} - -// snapshotName turns a command path like ["configure", "add", "env/infisical"] -// into a safe filename "configure_add_env-infisical.txt". The slash inside -// "env/infisical" is replaced with a dash so we don't accidentally write -// into a nested directory. -func snapshotName(path []string) string { - if len(path) == 0 { - return "root.txt" - } - parts := make([]string, len(path)) - for i, p := range path { - parts[i] = strings.ReplaceAll(p, "/", "-") - } - return strings.Join(parts, "_") + ".txt" -} - -// renderHelp returns the same output a user gets from `one --help`. -// We capture cobra's default HelpFunc into a buffer rather than exec'ing -// the binary — same template, deterministic across machines, doesn't -// require `mise run build` first. -func renderHelp(t *testing.T, cmd *cobra.Command) string { - t.Helper() - buf := &bytes.Buffer{} - cmd.SetOut(buf) - cmd.SetErr(buf) - // Cobra's default HelpFunc is set on the root command and inherits - // down the tree; calling Help() on any subcommand uses it. - if err := cmd.Help(); err != nil { - t.Fatalf("cmd.Help() for %q failed: %v", cmd.CommandPath(), err) - } - return buf.String() -} - -// walkTree returns every command in the tree under root in stable -// alphabetical order, paired with its path relative to root (the root -// itself gets an empty path). Hidden / deprecated commands are skipped -// the way cobra would skip them in --help output, so what we snapshot -// is what the user sees. -type cmdWalk struct { - cmd *cobra.Command - path []string -} - -func walkTree(root *cobra.Command) []cmdWalk { - var out []cmdWalk - var visit func(c *cobra.Command, path []string) - visit = func(c *cobra.Command, path []string) { - out = append(out, cmdWalk{cmd: c, path: append([]string{}, path...)}) - children := append([]*cobra.Command{}, c.Commands()...) - sort.Slice(children, func(i, j int) bool { - return children[i].Name() < children[j].Name() - }) - for _, child := range children { - if child.Hidden || child.Deprecated != "" { - continue - } - // Cobra auto-injects a `help` command on every parent with - // subcommands. It's identical across every command, so - // snapshotting it everywhere would be pure noise. - if child.Name() == "help" { - continue - } - visit(child, append(path, child.Name())) - } - } - visit(root, nil) - // Drop the root entry (we snapshot the intercepted rootHelp - // constant separately — cobra's auto-generated root help is never - // shown to users). - if len(out) > 0 && len(out[0].path) == 0 { - out = out[1:] - } - return out -} - -// assertHelpSnapshot compares got against the file at testdata/reference/help/. -// Mismatch fails with a hint to re-run with UPDATE_SNAPSHOTS=1. -func assertHelpSnapshot(t *testing.T, name, got string) { - t.Helper() - dir := helpSnapshotDir(t) - path := filepath.Join(dir, name) - - if os.Getenv("UPDATE_SNAPSHOTS") == "1" { - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatalf("mkdir %s: %v", dir, err) - } - if err := os.WriteFile(path, []byte(got), 0o644); err != nil { - t.Fatalf("write %s: %v", path, err) - } - t.Logf("UPDATE_SNAPSHOTS=1: wrote %s (%d bytes)", path, len(got)) - return - } - - want, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read %s: %v\n hint: run with UPDATE_SNAPSHOTS=1 to create", path, err) - } - if string(want) != got { - t.Errorf("help-text drift vs %s\n--- want\n%s\n--- got\n%s\n--- hint\nUPDATE_SNAPSHOTS=1 go test ./tests/e2e/ -run TestHelpSnapshots", - path, string(want), got) - } -} - -// TestHelpSnapshots locks every subcommand's --help text against a -// per-command .txt fixture. Drives the cobra tree from RootCmd() so new -// subcommands automatically participate. -func TestHelpSnapshots(t *testing.T) { - root := cli.RootCmd() - walks := walkTree(root) - if len(walks) == 0 { - t.Fatal("walkTree returned 0 commands — registration broken?") - } - for _, w := range walks { - name := snapshotName(w.path) - t.Run(name, func(t *testing.T) { - got := renderHelp(t, w.cmd) - assertHelpSnapshot(t, name, got) - }) - } -} - -// TestRootHelpSnapshot locks the curated root help text (the -// i18n-resolved "root.help" key). Bypasses cobra because `one --help` -// does too (see shouldRenderRootHelp in Execute). Catches drift in -// the deliberately concise everyday-command block. -// -// Renders in DefaultLocale (en-US) for snapshot determinism — the -// snapshot is the English fixture; per-locale snapshots can be -// layered in later if we localise further. -func TestRootHelpSnapshot(t *testing.T) { - _ = i18n.Init(i18n.DefaultLocale) - assertHelpSnapshot(t, "root.txt", cli.RootHelp()) -} diff --git a/packages/cli/tests/e2e/startup_terminal_unix_test.go b/packages/cli/tests/e2e/startup_terminal_unix_test.go deleted file mode 100644 index db6b6fa8..00000000 --- a/packages/cli/tests/e2e/startup_terminal_unix_test.go +++ /dev/null @@ -1,75 +0,0 @@ -//go:build unix - -package cli_test - -import ( - "strings" - "testing" - - "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" -) - -func TestE2E_NoninteractiveStartupDoesNotQueryTerminal(t *testing.T) { - root := t.TempDir() - isolateHome(t, root) - buildWrite(t, root, "one.manifest.toml", "version = 2\n[workspace]\nid = 'startup'\nname = 'startup'\n") - buildWrite(t, root, "mise.toml", "[tasks.dev]\nrun = 'echo SHOULD_NOT_RUN'\n") - for _, locale := range []string{"en_US.UTF-8", "zh_CN.UTF-8"} { - t.Run(locale, func(t *testing.T) { - t.Setenv("LC_ALL", locale) - for _, tc := range []struct { - name string - args []string - want string - }{ - {"version", []string{"--version"}, expectedBuildVersion(t)}, - {"help", []string{"dev", "--help"}, "--project"}, - {"preview", []string{"dev", "--dry-run"}, "//:dev"}, - } { - t.Run(tc.name, func(t *testing.T) { - terminal := startTaskTerminal(t, root, tc.args...) - terminal.wait(t, 0) - got := terminal.out.String() - if !strings.Contains(got, tc.want) || strings.Contains(got, "SHOULD_NOT_RUN") || strings.Contains(got, "[one]") { - t.Fatalf("unexpected output: %q", got) - } - // Even a helper's package initialization can query the terminal - // before main. These commands must not depend on a reply; an - // unresponsive terminal would otherwise stall every invocation. - for _, query := range []string{"\x1b]11;?", "\x1b[c", "\x1b[6n"} { - if strings.Contains(got, query) { - t.Fatalf("startup queried the terminal with %q: %q", query, got) - } - } - }) - } - }) - } -} - -func TestE2E_TaskStartupProgressIsLocalized(t *testing.T) { - for _, locale := range []struct{ id, env string }{{"en-US", "en_US.UTF-8"}, {"zh-CN", "zh_CN.UTF-8"}} { - t.Run(locale.id, func(t *testing.T) { - root := t.TempDir() - isolateHome(t, root) - t.Setenv("LC_ALL", locale.env) - if err := i18n.Init(locale.id); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = i18n.Init("en-US") }) - buildWrite(t, root, "one.manifest.toml", "version = 2\n[workspace]\nid = 'startup'\nname = 'startup'\n") - buildWrite(t, root, "mise.toml", "[tasks.dev]\nrun = 'echo CHILD_STARTED'\n") - terminal := startTaskTerminal(t, root, "dev", "--ui", "stream") - terminal.wait(t, 0) - got := terminal.out.String() - last := -1 - for _, want := range []string{i18n.T("tasks.preparing"), i18n.T("tasks.preparing_dependencies"), i18n.Tf("tasks.preparing_environment", "dev"), "CHILD_STARTED"} { - at := strings.Index(got, want) - if at <= last { - t.Fatalf("missing/out of order %q: %q", want, got) - } - last = at - } - }) - } -} diff --git a/packages/cli/tests/e2e/task_shortcuts_test.go b/packages/cli/tests/e2e/task_shortcuts_test.go deleted file mode 100644 index ffad50a5..00000000 --- a/packages/cli/tests/e2e/task_shortcuts_test.go +++ /dev/null @@ -1,177 +0,0 @@ -package cli_test - -import ( - "encoding/json" - "os" - "path/filepath" - "reflect" - "regexp" - "strings" - "testing" -) - -func TestE2E_TaskShortcutsAndBuiltinPrecedence(t *testing.T) { - root := buildFixture(t, true) - buildWrite(t, root, "mise.toml", `[tools] -node="system" -pnpm="system" -[tasks.hello] -run = 'sh hello.sh' -raw_args = true -[tasks.env] -run = 'echo TASK_ENV' -[tasks.dev] -run = 'echo ROOT_TASK_OVERRIDE' -depends = [] -[tasks.build] -run = 'echo ROOT_TASK_OVERRIDE' -depends = [] -[tasks.test] -run = 'echo ROOT_TASK_OVERRIDE' -depends = [] -[tasks.lint] -run = 'echo ROOT_TASK_OVERRIDE' -depends = [] -`) - buildWrite(t, root, "hello.sh", "printf '<%s>\\n' \"$@\"\n") - for _, prefix := range [][]string{{"hello"}, {"run", "hello"}} { - args := append(append([]string{}, prefix...), "-o", "json", "--", "a b", "$(touch INJECTED)", "--output", "text", "--help") - out, logs, code := runBinaryIn(t, root, args...) - if code != 0 || !json.Valid([]byte(out)) || !strings.Contains(regexp.MustCompile(`(?m)\[//:[^\]]+\]\s*`).ReplaceAllString(logs, ""), "\n<$(touch INJECTED)>\n<--output>\n\n<--help>") { - t.Fatalf("exit=%d out=%s logs=%s", code, out, logs) - } - } - if _, err := os.Stat(filepath.Join(root, "INJECTED")); !os.IsNotExist(err) { - t.Fatal("task argument was evaluated as shell code") - } - out, logs, code := runBinaryIn(t, root, "env", "--help") - if code != 0 || strings.Contains(out+logs, "TASK_ENV") { - t.Fatal(out, logs) - } - _, logs, code = runBinaryIn(t, root, "run", "env", "-o", "json") - if code != 0 || !strings.Contains(logs, "TASK_ENV") { - t.Fatal(logs) - } - for _, name := range []string{"dev", "build", "test", "lint"} { - for _, args := range [][]string{{name}, {"run", name}} { - _, logs, code = runBinaryIn(t, root, append(args, "-o", "json")...) - if code != 0 || !strings.Contains(logs, "ROOT_TASK_OVERRIDE") { - t.Fatal(args, code, logs) - } - } - } -} - -func TestE2E_TaskShortcutPreviewAndHelpAreStatic(t *testing.T) { - root := buildFixture(t, true) - buildWrite(t, root, "packages/lib/package.json", `{"name":"@build/lib","scripts":{"dev":"sh dev.sh","test":"sh test.sh","build":"sh build.sh","lint":"sh lint.sh"}}`) - syncFixtureTasks(t, root) - t.Setenv("ONE_MISE_BINARY", filepath.Join(root, "missing-mise")) - for _, name := range []string{"dev", "build", "test", "lint"} { - short, logs, code := runBinaryIn(t, root, name, "-p", "lib", "--dry-run", "-o", "json") - if code != 0 { - t.Fatal(name, logs) - } - long, logs, code := runBinaryIn(t, root, "run", name, "-p", "lib", "--dry-run", "-o", "json") - if code != 0 { - t.Fatal(name, logs) - } - if !reflect.DeepEqual(mustParseJSON(t, short), mustParseJSON(t, long)) { - t.Fatalf("%s preview differs", name) - } - } - outside := t.TempDir() - for _, locale := range []string{"en_US.UTF-8", "zh_CN.UTF-8"} { - t.Setenv("LC_ALL", locale) - want, logs, code := runBinaryIn(t, outside, "run", "--help") - if code != 0 || !strings.Contains(want, "--project") || strings.Contains(want, "tasks.") { - t.Fatal(want, logs) - } - for _, dir := range []string{root, outside} { - for _, name := range []string{"dev", "build", "test", "lint"} { - for _, args := range [][]string{{name, "--help"}, {"run", name, "--help"}} { - out, logs, code := runBinaryIn(t, dir, args...) - if code != 0 || out != want { - t.Fatalf("%s %v help differs from run: exit=%d out=%s logs=%s", locale, args, code, out, logs) - } - } - } - } - } - for _, path := range []string{"installs", "node_modules", "apps/web/mise.toml"} { - if _, err := os.Stat(filepath.Join(root, path)); !os.IsNotExist(err) { - t.Fatalf("preview wrote %s", path) - } - } -} - -func TestE2E_ScriptAndExplicitMiseDevForwardArguments(t *testing.T) { - for _, manifest := range []bool{false, true} { - t.Run(map[bool]string{false: "native", true: "explicit mise"}[manifest], func(t *testing.T) { - root := buildFixture(t, true) - if manifest { - overrideDevCommand(t, root, "lib", "sh dev.sh") - } else { - buildWrite(t, root, "packages/lib/package.json", `{"name":"@build/lib","scripts":{"dev":"sh dev.sh"}}`) - syncFixtureTasks(t, root) - } - buildWrite(t, root, "packages/lib/dev.sh", "printf '<%s>\\n' \"$@\"\n") - _, logs, code := runBinaryIn(t, root, "dev", "-p", "lib", "-o", "json", "--", "a b", "$(touch INJECTED)", "--port", "4300") - if code != 0 || !strings.Contains(regexp.MustCompile(`(?m)\[//:[^\]]+\]\s*`).ReplaceAllString(logs, ""), "\n<$(touch INJECTED)>\n<--port>\n<4300>") { - t.Fatal(code, logs) - } - if _, err := os.Stat(filepath.Join(root, "packages/lib/INJECTED")); !os.IsNotExist(err) { - t.Fatal("dev argument was evaluated") - } - }) - } -} - -func TestE2E_TaskValidationRejectsInteractiveGraphBeforePreparation(t *testing.T) { - root := buildFixture(t, true) - for _, project := range []string{"web", "lib"} { - overrideDevCommand(t, root, project, "echo DEV_SHOULD_NOT_START") - } - - appendRootTaskConfig(t, root, `[tasks.dev] - depends=["web:dev","lib:dev"] - [tasks."web:dev"] - interactive=true - `) - for _, locale := range []string{"zh_CN.UTF-8", "en_US.UTF-8"} { - t.Setenv("LC_ALL", locale) - for _, args := range [][]string{{"dev"}, {"dev", "--ui", "tui"}, {"test", "--", ":::"}} { - out, logs, code := runBinaryIn(t, root, append(args, "--dry-run")...) - if code == 0 || strings.Contains(out+logs, "DEV_SHOULD_NOT_START") || strings.Contains(logs, `"message": "tasks.`) { - t.Fatal(args, code, out, logs) - } - } - } - if _, err := os.Stat(filepath.Join(root, "installs")); !os.IsNotExist(err) { - t.Fatal("validation installed dependencies") - } -} - -func TestE2E_TaskListGroupsAndVerbose(t *testing.T) { - root := buildFixture(t, true) - for _, locale := range []string{"en_US.UTF-8", "zh_CN.UTF-8"} { - t.Setenv("LC_ALL", locale) - out, logs, code := runBinaryIn(t, root, "run", "-o", "text") - if code != 0 || strings.Contains(out, "mise.toml") || strings.Contains(out, "//:") || !strings.Contains(out, "one run --verbose") { - t.Fatal(code, out, logs) - } - detailed, logs, code := runBinaryIn(t, root, "run", "--verbose", "-o", "text") - if code != 0 || !strings.Contains(detailed, "mise.toml") { - t.Fatal(code, detailed, logs) - } - out, logs, code = runBinaryIn(t, root, "run", "-p", "web", "-o", "text") - if code != 0 || !strings.Contains(out, "web:build") || strings.Contains(out, "lib:build") { - t.Fatal(code, out, logs) - } - plain, _, code := runBinaryIn(t, root, "run", "-o", "json") - verbose, _, verboseCode := runBinaryIn(t, root, "run", "--verbose", "-o", "json") - if code != 0 || verboseCode != 0 || !reflect.DeepEqual(mustParseJSON(t, plain), mustParseJSON(t, verbose)) { - t.Fatal("verbose changed the catalog protocol", plain, verbose) - } - } -} diff --git a/packages/cli/tests/e2e/task_terminal_unix_test.go b/packages/cli/tests/e2e/task_terminal_unix_test.go deleted file mode 100644 index fe020d46..00000000 --- a/packages/cli/tests/e2e/task_terminal_unix_test.go +++ /dev/null @@ -1,469 +0,0 @@ -//go:build unix - -package cli_test - -import ( - "fmt" - process "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/process" - "io" - "os" - "os/exec" - "path/filepath" - "regexp" - "runtime" - "strings" - "sync/atomic" - "testing" - "time" - - "github.com/charmbracelet/x/ansi" - "github.com/creack/pty" -) - -type taskTerminal struct { - cmd *exec.Cmd - pty *os.File - out *lockedBuffer - done chan error - readDone chan struct{} - finished atomic.Bool -} - -func startTaskTerminal(t *testing.T, root string, args ...string) *taskTerminal { - t.Helper() - cmd := exec.Command(binaryPath(t), args...) - cmd.Dir = root - cmd.Env = replaceEnvValues(prependPath(os.Environ(), filepath.Dir(binaryPath(t))), map[string]string{"TERM": "xterm-256color", "CI": "", "NO_COLOR": ""}) - pt, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 30, Cols: 110}) - if err != nil { - t.Fatal(err) - } - tt := &taskTerminal{cmd: cmd, pty: pt, out: &lockedBuffer{}, done: make(chan error, 1), readDone: make(chan struct{})} - go func() { - defer close(tt.readDone) - // Lip Gloss stops reading once it gets the device-attributes reply. - // Send the background reply first so it cannot leak into child stdin. - replies := []struct{ query, response string }{ - {"\x1b]11;?", "\x1b]11;rgb:0000/0000/0000\x1b\\"}, - {"\x1b[c", "\x1b[?1;2c"}, - {"\x1b[6n", "\x1b[1;1R"}, - } - counts := map[string]int{} - buf := make([]byte, 8192) - for { - n, err := pt.Read(buf) - if n > 0 { - _, _ = tt.out.Write(buf[:n]) - all := tt.out.String() - for _, reply := range replies { - count := strings.Count(all, reply.query) - for counts[reply.query] < count { - _, _ = io.WriteString(pt, reply.response) - counts[reply.query]++ - } - } - } - if err != nil { - return - } - } - }() - go func() { err := cmd.Wait(); tt.finished.Store(true); tt.done <- err }() - t.Cleanup(func() { - if !tt.finished.Load() { - _ = process.StopTree(cmd.Process) - } - _ = pt.Close() - }) - return tt -} -func (tt *taskTerminal) wait(t *testing.T, want int) { - t.Helper() - select { - case err := <-tt.done: - code := 0 - if ex, ok := err.(*exec.ExitError); ok { - code = ex.ExitCode() - } else if err != nil { - t.Fatal(err) - } - if code != want { - t.Fatalf("exit=%d want=%d\n%s", code, want, tt.out.String()) - } - case <-time.After(8 * time.Second): - t.Fatalf("terminal did not finish\n%s", tt.out.String()) - } - select { - case <-tt.readDone: - case <-time.After(time.Second): - _ = tt.pty.Close() - } -} -func devTerminalFixture(t *testing.T, mise bool) string { - root := buildFixture(t, mise) - if err := os.MkdirAll(filepath.Join(root, "node_modules"), 0755); err != nil { - t.Fatal(err) - } - // The fixture owns explicit native mise tasks, including the aggregate. - raw, err := os.ReadFile(filepath.Join(root, "mise.toml")) - if err != nil { - t.Fatal(err) - } - extra := ` -[tasks.dev] -depends=["web:dev","lib:dev"] -[tasks."web:dev"] -dir="apps/web" -run="sh dev.sh" -raw_args=true -depends=["lib:build"] -[tasks."lib:dev"] -dir="packages/lib" -run="sh dev.sh" -raw_args=true -` - buildWrite(t, root, "mise.toml", string(raw)+extra) - return root -} - -func TestE2E_SingleDevAndBuildKeepNativeTTY(t *testing.T) { - for _, command := range []string{"dev", "build"} { - t.Run(command, func(t *testing.T) { - root := devTerminalFixture(t, false) - script := "test -t 0 && test -t 1 && test -t 2 || exit 91\nprintf '\\033[35mNATIVE_READY\\033[0m'\nread value\nprintf '\\rNATIVE_REPLY:%s\\n' \"$value\"\n" - buildWrite(t, root, "packages/lib/"+command+".sh", script) - args := []string{command, "-p", "lib", "-o", "text", "--ui", "raw"} - tt := startTaskTerminal(t, root, args...) - waitForTTYOutput(t, tt.out, "NATIVE_READY", 8*time.Second) - _, _ = tt.pty.Write([]byte("hello\r")) - tt.wait(t, 0) - got := tt.out.String() - if !strings.Contains(got, "\x1b[35mNATIVE_READY\x1b[0m") || !strings.Contains(got, "NATIVE_REPLY:hello") || strings.Contains(got, "lib | NATIVE") || strings.Contains(got, "[lib] NATIVE") { - t.Fatal(got) - } - }) - } -} - -func TestE2E_SingleDevAndBuildExitWithIdleTerminalInput(t *testing.T) { - for _, command := range []string{"dev", "build"} { - for _, tc := range []struct { - name string - code int - }{{"success", 0}, {"failure", 42}} { - t.Run(command+"/"+tc.name, func(t *testing.T) { - root := devTerminalFixture(t, false) - // The input relay remains blocked while the child finishes without - // reading stdin. Exiting must cancel that read and keep the exit code. - script := fmt.Sprintf("test -t 0 && test -t 1 && test -t 2 || exit 91\nprintf 'NATIVE_DONE\\n'\nexit %d\n", tc.code) - buildWrite(t, root, "packages/lib/"+command+".sh", script) - args := []string{command, "-p", "lib", "-o", "text", "--ui", "raw"} - tt := startTaskTerminal(t, root, args...) - tt.wait(t, tc.code) - if !strings.Contains(tt.out.String(), "NATIVE_DONE") { - t.Fatal(tt.out.String()) - } - }) - } - } -} - -func TestE2E_BuildRawRejectsMultipleCommandsBeforeStarting(t *testing.T) { - root := devTerminalFixture(t, false) - buildWrite(t, root, "packages/lib/build.sh", "touch SHOULD_NOT_RUN\n") - tt := startTaskTerminal(t, root, "build", "--ui=raw", "-o", "text") - tt.wait(t, 1) - if _, err := os.Stat(filepath.Join(root, "packages/lib/SHOULD_NOT_RUN")); !os.IsNotExist(err) { - t.Fatal("raw rejection launched a command") - } -} - -func TestE2E_DevHonorsMiseProjectOverrideAndRawTerminal(t *testing.T) { - root := devTerminalFixture(t, true) - appendRootTaskConfig(t, root, `[tasks."lib:dev"] -dir = "packages/lib" -run = "sh override.sh" -raw = true -`) - buildWrite(t, root, "packages/lib/override.sh", "test -t 0 && test -t 1 && test -t 2 || exit 91\necho USER_DEV_READY\nread value\necho USER_DEV_REPLY:$value\n") - buildWrite(t, root, "packages/lib/dev.sh", "echo WRONG_MANIFEST_COMMAND\nexit 92\n") - tt := startTaskTerminal(t, root, "dev", "-p", "lib", "-o", "text") - waitForTTYOutput(t, tt.out, "USER_DEV_READY", 8*time.Second) - _, _ = tt.pty.Write([]byte("hello\r")) - tt.wait(t, 0) - if !strings.Contains(tt.out.String(), "USER_DEV_REPLY:hello") || strings.Contains(tt.out.String(), "WRONG_MANIFEST_COMMAND") { - t.Fatal(tt.out.String()) - } -} - -func TestE2E_TaskTUIRestoresTerminalAndCancelsTree(t *testing.T) { - for _, mode := range []string{"auto", "tui"} { - t.Run(mode, func(t *testing.T) { - root := devTerminalFixture(t, false) - buildWrite(t, root, "packages/lib/dev.sh", "echo $$ > ../../lib-dev.pid; sleep 60 & echo $! > ../../lib-child.pid; wait\n") - buildWrite(t, root, "apps/web/dev.sh", "echo TUI_WEB_READY; sleep 60\n") - tt := startTaskTerminal(t, root, "run", "dev", "--ui", mode, "-o", "text") - deadline := time.Now().Add(8 * time.Second) - for time.Now().Before(deadline) { - if _, err := os.Stat(filepath.Join(root, "lib-child.pid")); err == nil { - break - } - time.Sleep(20 * time.Millisecond) - } - if _, err := os.Stat(filepath.Join(root, "lib-child.pid")); err != nil { - t.Fatal("task did not start", err) - } - waitForTTYOutput(t, tt.out, "\x1b[?1049h", 8*time.Second) - if !strings.Contains(tt.out.String(), "\x1b[?1049h") { - t.Fatalf("TUI did not enter alternate screen: %s", tt.out.String()) - } - if err := pty.Setsize(tt.pty, &pty.Winsize{Rows: 12, Cols: 45}); err != nil { - t.Fatal(err) - } - _, _ = tt.pty.Write([]byte("\t\x1b[5~")) - time.Sleep(200 * time.Millisecond) - _, _ = tt.pty.Write([]byte{3}) - tt.wait(t, 130) - if !strings.Contains(tt.out.String(), "\x1b[?1049l") { - t.Fatalf("TUI did not restore terminal: %s", tt.out.String()) - } - }) - } -} -func TestE2E_TaskTUIKeepsChildFailure(t *testing.T) { - root := devTerminalFixture(t, false) - buildWrite(t, root, "packages/lib/build.sh", "echo TUI_FAILURE; exit 42\n") - tt := startTaskTerminal(t, root, "run", "build", "-p", "lib", "--ui", "tui", "-o", "text") - tt.wait(t, 42) - if !strings.Contains(tt.out.String(), "\x1b[?1049l") { - t.Fatal(tt.out.String()) - } -} - -func TestE2E_TaskTUIFullHistoryAndResize(t *testing.T) { - root := devTerminalFixture(t, false) - buildWrite(t, root, "packages/lib/build.sh", `printf '\033[1;35mFIRST_HISTORY_MARKER\033[0m\n' - i=0 - while [ "$i" -lt 12000 ]; do - printf 'line %s 中文 long log content that must wrap and stay complete........................................................\n' "$i" - i=$((i+1)) - done - printf 'LAST_HISTORY_MARKER\n' - sleep 60 - `) - tt := startTaskTerminal(t, root, "run", "build", "-p", "lib", "--ui", "tui", "-o", "text") - waitForTTYOutput(t, tt.out, "LAST_HISTORY_MARKER", 15*time.Second) - // Browse all history while the task is still running, including logs that - // exceed the former 4,000-line buffer. - mark := len(tt.out.String()) - _, _ = tt.pty.Write([]byte("\x1b[H")) - deadline := time.Now().Add(5 * time.Second) - for time.Now().Before(deadline) && !strings.Contains(tt.out.String()[mark:], "FIRST_HISTORY_MARKER") { - time.Sleep(20 * time.Millisecond) - } - recent := tt.out.String()[mark:] - if !strings.Contains(recent, "FIRST_HISTORY_MARKER") { - t.Fatalf("history/prefix/style lost: %q", recent) - } - for _, size := range []pty.Winsize{{Rows: 12, Cols: 45}, {Rows: 30, Cols: 140}} { - if err := pty.Setsize(tt.pty, &size); err != nil { - t.Fatal(err) - } - time.Sleep(100 * time.Millisecond) - } - // Mouse scrolling, keyboard paging and returning to the live tail. - _, _ = tt.pty.Write([]byte("\x1b[<65;35;5M\x1b[6~\x1b[F")) - waitForTTYOutput(t, tt.out, "LAST_HISTORY_MARKER", 5*time.Second) - _, _ = tt.pty.Write([]byte{3}) - tt.wait(t, 130) - if !strings.Contains(tt.out.String(), "\x1b[?1049l") { - t.Fatal("terminal was not restored") - } -} - -func TestE2E_InstallTaskTUIExitsWithoutKeyboardInput(t *testing.T) { - for _, mode := range []string{"auto", "tui"} { - t.Run(mode, func(t *testing.T) { - root := devTerminalFixture(t, false) - appendRootTaskConfig(t, root, `[tasks.install] - depends=["web:build"] - run="echo INSTALL_FINISHED" - `) - tt := startTaskTerminal(t, root, "install", "--ui", mode, "-o", "text") - // Do not send q, Enter, Ctrl+C, or close stdin: completion alone must exit. - tt.wait(t, 0) - got := tt.out.String() - for _, want := range []string{"\x1b[?1049h", "\x1b[?1049l"} { - if !strings.Contains(got, want) { - t.Fatalf("missing %q in %s", want, got) - } - } - }) - } -} - -func TestE2E_TaskTUIDependencyTree(t *testing.T) { - root := devTerminalFixture(t, false) - appendRootTaskConfig(t, root, `[tasks.tree-demo] - depends=["tree-api","tree-web"] - [tasks.tree-api] - depends=["tree-prepare"] - run="echo TREE_API_READY; sleep 60" - [tasks.tree-web] - depends=["tree-prepare"] - run="echo TREE_WEB_READY; sleep 60" - [tasks.tree-prepare] - run="echo TREE_SHARED_OUTPUT" - `) - tt := startTaskTerminal(t, root, "run", "tree-demo", "--ui", "tui", "-o", "text") - waitForTaskText(t, tt.out, "● tree-api", 8*time.Second) - waitForTaskText(t, tt.out, "● tree-web", 8*time.Second) - waitForTTYOutput(t, tt.out, "Task dependency tree", 5*time.Second) - // Reveal shared dependencies in the tree with sidebar arrow navigation. - _, _ = tt.pty.Write([]byte("\t\x1b[F\x1b[C")) - waitForTTYOutput(t, tt.out, "tree-prepare", 5*time.Second) - waitForTTYOutput(t, tt.out, "↪", 5*time.Second) - time.Sleep(200 * time.Millisecond) - if err := pty.Setsize(tt.pty, &pty.Winsize{Rows: 18, Cols: 45}); err != nil { - t.Fatal(err) - } - - _, _ = tt.pty.Write([]byte{3}) - tt.wait(t, 130) - if !strings.Contains(tt.out.String(), "\x1b[?1049l") { - t.Fatal("tree view did not restore terminal") - } -} - -func TestE2E_TaskListTerminalStyles(t *testing.T) { - for _, color := range []bool{true, false} { - t.Run(fmt.Sprint(color), func(t *testing.T) { - root := buildFixture(t, true) - t.Setenv("FORCE_COLOR", "") - if color { - t.Setenv("CLICOLOR", "") - } else { - t.Setenv("CLICOLOR", "0") - } - tt := startTaskTerminal(t, root, "run") - tt.wait(t, 0) - got := tt.out.String() - // Terminal capability probes are not visible SGR color/style sequences. - if regexp.MustCompile(`\x1b\[[0-9;]*m`).MatchString(got) != color || strings.Contains(got, "\x1b[?1049h") { - t.Fatalf("unexpected task list styling: %q", got) - } - for _, want := range []string{"Workspace tasks", "Project: web", "web:build", "one run --verbose"} { - if !strings.Contains(ansi.Strip(got), want) { - t.Fatalf("missing %q: %s", want, got) - } - } - }) - } -} - -func TestE2E_TaskTUISelectionFreezesLogsWhileProcessesContinue(t *testing.T) { - root := devTerminalFixture(t, false) - appendRootTaskConfig(t, root, `[tasks.copy-demo] - run="sh copy.sh" - `) - buildWrite(t, root, "copy.sh", "printf 'COPY_BEFORE\\n'\nwhile [ ! -f next ]; do sleep .05; done\nprintf 'COPY_AFTER\\n'\ntouch produced\nsleep 60\n") - tt := startTaskTerminal(t, root, "run", "copy-demo", "--ui", "tui", "-o", "text") - waitForTTYOutput(t, tt.out, "COPY_BEFORE", 8*time.Second) - _, _ = tt.pty.Write([]byte("c")) - waitForTTYOutput(t, tt.out, "Native copy", 5*time.Second) - waitForTTYOutput(t, tt.out, "\x1b[?1002l", 5*time.Second) - start := len(tt.out.String()) - os.WriteFile(filepath.Join(root, "next"), nil, 0600) - deadline := time.Now().Add(5 * time.Second) - for time.Now().Before(deadline) { - if _, err := os.Stat(filepath.Join(root, "produced")); err == nil { - break - } - time.Sleep(10 * time.Millisecond) - } - if _, err := os.Stat(filepath.Join(root, "produced")); err != nil { - t.Fatal("copy mode stopped child") - } - if strings.Contains(tt.out.String()[start:], "COPY_AFTER") { - t.Fatal("native snapshot followed new logs") - } - // Returning from the frozen screen restores live logs and mouse reporting. - _, _ = tt.pty.Write([]byte("\x1b")) - time.Sleep(300 * time.Millisecond) - _, _ = tt.pty.Write([]byte("f")) - - if err := pty.Setsize(tt.pty, &pty.Winsize{Rows: 28, Cols: 100}); err != nil { - t.Fatal(err) - } - waitForTTYOutput(t, tt.out, "COPY_AFTER", 5*time.Second) - - _, _ = tt.pty.Write([]byte{3}) - tt.wait(t, 130) -} - -func TestE2E_TaskTUISilentRunningAndSelectedCompletion(t *testing.T) { - root := devTerminalFixture(t, false) - appendRootTaskConfig(t, root, `[tasks.silent-demo] - depends=["silent"] - run="echo STILL_RUNNING; sleep 60" - [tasks.silent] - run="sh silent.sh" - `) - buildWrite(t, root, "silent.sh", "touch started\nwhile [ ! -f finish ]; do sleep .05; done\n") - tt := startTaskTerminal(t, root, "run", "silent-demo", "--ui", "tui", "-o", "text") - waitForTaskText(t, tt.out, "● silent", 8*time.Second) - _, _ = tt.pty.Write([]byte("]")) - waitForTTYOutput(t, tt.out, "//:silent ·", 5*time.Second) - if err := os.WriteFile(filepath.Join(root, "finish"), nil, 0600); err != nil { - t.Fatal(err) - } - waitForTaskText(t, tt.out, "completed", 5*time.Second) - if tt.finished.Load() { - t.Fatal("selected task completion stopped whole invocation") - } - _, _ = tt.pty.Write([]byte{3}) - tt.wait(t, 130) -} - -// Bubble Tea redraws only changed cells; don't expect complete updated lines -// to appear contiguously in the raw PTY byte stream. -func waitForTaskText(t *testing.T, output *lockedBuffer, want string, timeout time.Duration) { - t.Helper() - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - if strings.Contains(ansi.Strip(output.String()), want) { - return - } - time.Sleep(10 * time.Millisecond) - } - t.Fatalf("missing task text %q", want) -} - -func TestE2E_TaskTUICopiesThroughLocalClipboardBackend(t *testing.T) { - if runtime.GOOS != "darwin" { - t.Skip("macOS clipboard backend fixture") - } - root := devTerminalFixture(t, false) - for _, name := range []string{"SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"} { - t.Setenv(name, "") - } - buildWrite(t, root, "tools/pbcopy", "#!/bin/sh\ncat > "+"\""+filepath.Join(root, "copied")+"\""+"\n") - buildWrite(t, root, "copy-backend.sh", "printf '\033[32mCOPY_中文🧑‍💻é\033[0m\n'\nsleep 60\n") - appendRootTaskConfig(t, root, `[tasks.copy-backend] -run="sh copy-backend.sh" -`) - - tt := startTaskTerminal(t, root, "run", "copy-backend", "--ui", "tui", "-o", "text") - waitForTTYOutput(t, tt.out, "COPY_", 8*time.Second) - _, _ = tt.pty.Write([]byte("yl")) - waitForTTYOutput(t, tt.out, "Copied to clipboard", 5*time.Second) - copied, err := os.ReadFile(filepath.Join(root, "copied")) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(copied), "COPY_中文🧑‍💻é") || strings.Contains(string(copied), "\x1b") || strings.Contains(string(copied), "Task dependency") { - t.Fatalf("clipboard content mismatch: %q", copied) - } - _, _ = tt.pty.Write([]byte{3}) - tt.wait(t, 130) -} diff --git a/packages/cli/tests/e2e/tasks_cache_test.go b/packages/cli/tests/e2e/tasks_cache_test.go deleted file mode 100644 index 56fb31c5..00000000 --- a/packages/cli/tests/e2e/tasks_cache_test.go +++ /dev/null @@ -1,212 +0,0 @@ -package cli_test - -import ( - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - - "github.com/pelletier/go-toml/v2" -) - -func appendRootTaskConfig(t *testing.T, root, value string) { - t.Helper() - path := filepath.Join(root, "mise.local.toml") - raw, err := os.ReadFile(path) - if err != nil && !os.IsNotExist(err) { - t.Fatal(err) - } - current := map[string]any{} - patch := map[string]any{} - if len(raw) > 0 { - if err = toml.Unmarshal(raw, ¤t); err != nil { - t.Fatal(err) - } - } - if err = toml.Unmarshal([]byte(value), &patch); err != nil { - t.Fatal(err) - } - var merge func(map[string]any, map[string]any) - merge = func(target, source map[string]any) { - for key, value := range source { - next, ok := value.(map[string]any) - if old, exists := target[key].(map[string]any); ok && exists { - merge(old, next) - } else { - target[key] = value - } - } - } - merge(current, patch) - raw, err = toml.Marshal(current) - if err != nil { - t.Fatal(err) - } - buildWrite(t, root, "mise.local.toml", string(raw)) -} - -func TestE2E_NativeMiseFileTask(t *testing.T) { - root := buildFixture(t, true) - buildWrite(t, root, ".mise/tasks/hello", "#!/bin/sh\necho native-file-task\n") - for _, args := range [][]string{{"run", "-o", "json"}, {"run", "hello", "--dry-run", "-o", "json"}} { - stdout, stderr, code := runBinaryIn(t, root, args...) - if code != 0 || !strings.Contains(stdout, "//:hello") { - t.Fatalf("%v: %d %s %s", args, code, stdout, stderr) - } - } - if _, err := os.Stat(filepath.Join(root, "apps/web/mise.toml")); !os.IsNotExist(err) { - t.Fatal("static discovery wrote managed configuration") - } - stdout, stderr, code := runBinaryIn(t, root, "run", "hello", "-o", "json") - if code != 0 || !strings.Contains(stderr, "native-file-task") || !strings.Contains(stdout, "one-cli/task-result/v1") { - t.Fatalf("%d %s %s", code, stdout, stderr) - } -} - -func TestE2E_NativeCacheNeedsNoOneCommand(t *testing.T) { - root := buildFixture(t, true) - t.Setenv("MISE_EXPERIMENTAL", "1") - appendRootTaskConfig(t, root, "[tasks.\"web:build\"]\nsources=['package.json']\noutputs=['dist']\ncache={enabled=true}\n") - stdout, stderr, code := runBinaryIn(t, root, "run", "build", "-p", "web", "-o", "json") - if code != 0 { - t.Fatalf("native cache failed: %d %s %s", code, stdout, stderr) - } - if _, err := os.Stat(filepath.Join(root, "order")); err != nil { - t.Fatal("native cached task did not run") - } -} - -func TestE2E_NativeMiseIncludedTOMLTask(t *testing.T) { - root := buildFixture(t, true) - buildWrite(t, root, "mise.toml", "[tools]\nnode='system'\npnpm='system'\n[task_config]\nincludes=['tasks.toml']\n") - buildWrite(t, root, "tasks.toml", "[hello]\nrun='echo included-task'\ndescription='Included task'\n") - for _, args := range [][]string{{"run", "-o", "json"}, {"run", "hello", "--dry-run", "-o", "json"}, {"run", "hello", "-o", "json"}} { - stdout, stderr, code := runBinaryIn(t, root, args...) - if code != 0 || !strings.Contains(stdout, "//:hello") { - t.Fatalf("%v: %d %s %s", args, code, stdout, stderr) - } - } -} - -func TestE2E_GoTaskBuildRebuildsExecutableAndForwardsArguments(t *testing.T) { - taskBinary, err := exec.LookPath("task") - if err != nil { - t.Skip("real Task is required for Go integration") - } - // Resolve the actual binary before isolating mise's data directory. A mise - // shim would otherwise look for this tool inside the empty fixture cache. - if out, err := exec.Command("mise", "which", "task").Output(); err == nil { - taskBinary = strings.TrimSpace(string(out)) - } - root := buildFixture(t, true) - // Own the fixture's VCS boundary rather than inheriting a parent repository - // or a sandbox's placeholder .git directory under the temporary root. - gitInit := exec.Command("git", "init", "--quiet") - gitInit.Dir = root - if err := gitInit.Run(); err != nil { - t.Fatal(err) - } - if err := os.Symlink(taskBinary, filepath.Join(root, "tools/task")); err != nil { - t.Fatal(err) - } - t.Setenv("CGO_ENABLED", "0") - buildWrite(t, root, "one.manifest.toml", `version = 2 - -[workspace] -id = "go-test" -name = "go-test" - -[projects."api"] -path = "services/api" -toolchain = "go" -`) - buildWrite(t, root, "mise.toml", "[tools]\ngo='system'\ntask='system'\nnode='system'\npnpm='system'\n") - buildWrite(t, root, "services/api/go.mod", "module example.com/task-test\n\ngo 1.25.0\n") - buildWrite(t, root, "services/api/cmd/server/main.go", "package main\nimport \"fmt\"\nvar message=\"default\"\nfunc main(){fmt.Print(message)}\n") - scaffold, err := os.ReadFile(filepath.Join(repoRoot(t), "../templates/go-api/Taskfile.yml")) - if err != nil { - t.Fatal(err) - } - buildWrite(t, root, "services/api/Taskfile.yml", string(scaffold)) - run := func(args ...string) { - t.Helper() - stdout, stderr, code := runBinaryIn(t, root, args...) - if code != 0 { - t.Fatalf("%v: %d %s %s", args, code, stdout, stderr) - } - } - check := func(want string) { - t.Helper() - out, err := exec.Command(filepath.Join(root, "services/api/bin/server")).CombinedOutput() - if err != nil || string(out) != want { - t.Fatalf("built executable: %s %v, want %s", out, err, want) - } - } - run("init", "mise") - run("build", "-p", "api") - check("default") - if err = os.RemoveAll(filepath.Join(root, "services/api/bin")); err != nil { - t.Fatal(err) - } - run("run", "build", "-p", "api") - check("default") - run("run", "build", "-p", "api", "--", "-ldflags=-X 'main.message=hello world'") - check("hello world") -} - -func TestE2E_ArtifactCacheStaysOffWithoutPublicFlag(t *testing.T) { - root := buildFixture(t, true) - // Ambient mise preferences cannot enable artifact caching through One. - t.Setenv("MISE_EXPERIMENTAL", "1") - t.Setenv("MISE_TASK_CACHE", "local-only") - t.Setenv("MISE_TASK_CACHE_DIR", t.TempDir()) - buildWrite(t, root, "apps/web/build.sh", "#!/bin/sh\nmkdir -p dist\necho artifact > dist/value\necho executed >> executions\nprintf '<%s>\\n' \"$@\"\n") - appendRootTaskConfig(t, root, `[tasks."web:build"] -sources = ["package.json", "build.sh"] -outputs = ["dist"] -cache = { enabled = true } -`) - run := func(extra ...string) string { - t.Helper() - args := append([]string{"run", "build", "-p", "web", "--ui", "stream"}, extra...) - out, stderr, code := runBinaryIn(t, root, args...) - if code != 0 { - t.Fatalf("%v: %d %s %s", args, code, out, stderr) - } - return out + stderr - } - check := func(want int) { - t.Helper() - raw, err := os.ReadFile(filepath.Join(root, "apps/web/executions")) - if err != nil || strings.Count(string(raw), "executed") != want { - t.Fatalf("executions = %q, want %d: %v", raw, want, err) - } - } - run() - check(1) - run() // Ordinary freshness checks are unchanged. - check(1) - if err := os.RemoveAll(filepath.Join(root, "apps/web/dist")); err != nil { - t.Fatal(err) - } - run() // Missing outputs must be rebuilt instead of restored from cache. - check(2) - run("--force") - check(3) - out := run("--", "a b", "中文", "$(nope)") - check(4) - for _, want := range []string{"", "<中文>", "<$(nope)>"} { - if !strings.Contains(out, want) { - t.Fatalf("lost argv %q: %s", want, out) - } - } - if _, _, code := runBinaryIn(t, root, "run", "build", "--cache", "off"); code == 0 { - t.Fatal("removed --cache flag was accepted") - } - for _, name := range []string{"__task", "__task-input", "__exec"} { - if _, _, code := runBinaryIn(t, root, name); code == 0 { - t.Fatalf("retired command %s is available", name) - } - } -} diff --git a/packages/cli/tools/install-local/main_test.go b/packages/cli/tools/install-local/main_test.go deleted file mode 100644 index c319d5cc..00000000 --- a/packages/cli/tools/install-local/main_test.go +++ /dev/null @@ -1,169 +0,0 @@ -package main - -import ( - "errors" - "os" - "path/filepath" - "runtime" - "strings" - "testing" -) - -func TestInstallUnixCreatesOneSymlink(t *testing.T) { - homeDir := t.TempDir() - targetPath := writeTestBinary(t, homeDir, "build/one") - var linkedTarget, linkedPath string - installer := localInstaller{ - goos: "linux", - homeDir: homeDir, - makeSymlink: func(target, path string) error { - linkedTarget, linkedPath = target, path - return os.WriteFile(path, []byte(target), 0o755) - }, - } - - result, err := installer.install(targetPath) - if err != nil { - t.Fatal(err) - } - wantPath := filepath.Join(homeDir, ".local", "bin", "one") - if result.launcherPath != wantPath || linkedPath != wantPath { - t.Fatalf("launcher path = %q, linked path = %q, want %q", result.launcherPath, linkedPath, wantPath) - } - if linkedTarget != result.targetPath { - t.Fatalf("linked target = %q, resolved target = %q", linkedTarget, result.targetPath) - } - if result.usedCommandShim { - t.Fatal("Unix install unexpectedly used a command shim") - } -} - -func TestInstallWindowsPrefersExeSymlink(t *testing.T) { - homeDir := t.TempDir() - targetPath := writeTestBinary(t, homeDir, "build/one.exe") - installer := localInstaller{ - goos: "windows", - homeDir: homeDir, - makeSymlink: func(target, path string) error { - return os.WriteFile(path, []byte(target), 0o755) - }, - } - - result, err := installer.install(targetPath) - if err != nil { - t.Fatal(err) - } - wantPath := filepath.Join(homeDir, ".local", "bin", "one.exe") - if result.launcherPath != wantPath { - t.Fatalf("launcher path = %q, want %q", result.launcherPath, wantPath) - } - if result.usedCommandShim { - t.Fatal("Windows install used a command shim even though symlink creation succeeded") - } -} - -func TestInstallWindowsFallsBackToCommandShim(t *testing.T) { - homeDir := t.TempDir() - targetPath := writeTestBinary(t, homeDir, "build/one.exe") - installer := localInstaller{ - goos: "windows", - homeDir: homeDir, - makeSymlink: func(string, string) error { return os.ErrPermission }, - } - - result, err := installer.install(targetPath) - if err != nil { - t.Fatal(err) - } - if !result.usedCommandShim || !errors.Is(result.symlinkErr, os.ErrPermission) { - t.Fatalf("fallback result = %+v", result) - } - if filepath.Base(result.launcherPath) != "one.cmd" { - t.Fatalf("launcher path = %q, want one.cmd", result.launcherPath) - } - body, err := os.ReadFile(result.launcherPath) - if err != nil { - t.Fatal(err) - } - text := string(body) - if !strings.Contains(text, "\""+result.targetPath+"\" %*") { - t.Fatalf("command shim does not forward arguments to target:\n%s", text) - } -} - -func TestInstallWindowsRefusesOrdinaryLegacyLauncher(t *testing.T) { - homeDir := t.TempDir() - targetPath := writeTestBinary(t, homeDir, "build/one.exe") - legacyPath := filepath.Join(homeDir, ".local", "bin", "one") - if err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(legacyPath, []byte("user-managed"), 0o644); err != nil { - t.Fatal(err) - } - installer := localInstaller{ - goos: "windows", - homeDir: homeDir, - makeSymlink: os.Symlink, - } - - _, err := installer.install(targetPath) - if err == nil || !strings.Contains(err.Error(), "not a symlink") { - t.Fatalf("expected a safe refusal for an ordinary legacy launcher, got %v", err) - } - body, readErr := os.ReadFile(legacyPath) - if readErr != nil { - t.Fatal(readErr) - } - if string(body) != "user-managed" { - t.Fatalf("legacy launcher was modified: %q", body) - } -} - -func TestWriteWindowsShimEscapesPercentCharacters(t *testing.T) { - path := filepath.Join(t.TempDir(), "one.cmd") - if err := writeWindowsShim(path, "C:\\work%20\\one.exe"); err != nil { - t.Fatal(err) - } - body, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(body), "C:\\work%%20\\one.exe") { - t.Fatalf("percent sign was not escaped:\n%s", body) - } -} - -func TestVerifyWindowsCommandShim(t *testing.T) { - if runtime.GOOS != "windows" { - t.Skip("cmd.exe verification is Windows-specific") - } - dir := t.TempDir() - targetPath := filepath.Join(dir, "fake one.cmd") - if err := os.WriteFile(targetPath, []byte("@echo off\r\nexit /b 0\r\n"), 0o755); err != nil { - t.Fatal(err) - } - launcherPath := filepath.Join(dir, "one.cmd") - if err := writeWindowsShim(launcherPath, targetPath); err != nil { - t.Fatal(err) - } - if err := verifyLauncher(localInstallResult{ - launcherPath: launcherPath, - targetPath: targetPath, - usedCommandShim: true, - }); err != nil { - t.Fatalf("verify command shim: %v", err) - } -} - -func writeTestBinary(t *testing.T, homeDir, relativePath string) string { - t.Helper() - path := filepath.Join(homeDir, filepath.FromSlash(relativePath)) - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte("binary"), 0o755); err != nil { - t.Fatal(err) - } - return path -} diff --git a/packages/cli/tools/repo-checks/main_test.go b/packages/cli/tools/repo-checks/main_test.go deleted file mode 100644 index 5fcf0f4f..00000000 --- a/packages/cli/tools/repo-checks/main_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package main - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestCheckNodeWorkspace(t *testing.T) { - root := t.TempDir() - writeTestFile(t, root, "apps/web/package.json", "{\"name\":\"web\"}") - if err := checkNodeWorkspace(root); err != nil { - t.Fatalf("clean workspace: %v", err) - } - writeTestFile(t, root, "apps/web/pnpm-lock.yaml", "lockfileVersion: 9\n") - if err := checkNodeWorkspace(root); err == nil || !strings.Contains(err.Error(), "pnpm-lock.yaml") { - t.Fatalf("duplicate lock should fail, got %v", err) - } -} - -func TestCheckFrontmatter(t *testing.T) { - root := t.TempDir() - writeTestFile(t, root, "apps/docs/content/docs/en/good.md", "---\ntitle: Good\ndescription: Fine\n---\n") - if err := checkFrontmatter(root); err != nil { - t.Fatalf("valid frontmatter: %v", err) - } - writeTestFile(t, root, "apps/docs/content/docs/en/bad.mdx", "---\ntitle: Bad\n---\n") - if err := checkFrontmatter(root); err == nil || !strings.Contains(err.Error(), "description") { - t.Fatalf("missing description should fail, got %v", err) - } -} - -func TestCheckGoFormatAcceptsCRLF(t *testing.T) { - root := t.TempDir() - formattedCRLF := "package sample\r\n\r\nfunc Value() int {\r\n\treturn 1\r\n}\r\n" - writeTestFile(t, root, "packages/kernel/sample.go", formattedCRLF) - writeTestFile(t, root, "packages/cli/sample.go", formattedCRLF) - - if err := checkGoFormat(root); err != nil { - t.Fatalf("gofmt-compatible CRLF source should pass: %v", err) - } -} - -func writeTestFile(t *testing.T, root, relative, body string) { - t.Helper() - path := filepath.Join(root, filepath.FromSlash(relative)) - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte(body), 0o644); err != nil { - t.Fatal(err) - } -} diff --git a/packages/cli/tools/sync-resources/main_test.go b/packages/cli/tools/sync-resources/main_test.go deleted file mode 100644 index 1154a646..00000000 --- a/packages/cli/tools/sync-resources/main_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package main - -import ( - "os" - "path/filepath" - "testing" -) - -func TestSyncBundledCopiesCanonicalAssetsAndMapsModuleFiles(t *testing.T) { - root := t.TempDir() - writeTestFile(t, root, "packages/templates/registry.json", "{}") - writeTestFile(t, root, "packages/templates/go-api/go.mod", "module example") - writeTestFile(t, root, "packages/templates/go-api/main.go", "package main") - retiredAssets := []string{ - "AGENTS.md", "CLAUDE.md", "SKILL.md", - ".one/agents/conventions.md", ".agents/skills/example/references/guide.md", - "nested/AGENTS.md", - "pnpm-lock.yaml", "nested/package-lock.json", "yarn.lock", - "bun.lock", "bun.lockb", "npm-shrinkwrap.json", - "node_modules/p/a.js", "dist/app.js", "apps/ui/build/app.js", "go.work", "go.work.sum", "tsconfig.tsbuildinfo", - } - for _, rel := range retiredAssets { - writeTestFile(t, root, "packages/templates/go-api/"+rel, "retired agent guidance") - } - - writeTestFile(t, root, "skills/one-cli/SKILL.md", "one-cli skill") - writeTestFile(t, root, "skills/one-migrate/SKILL.md", "retired skill") - writeTestFile(t, root, "skills/one-cli/references/old.md", "retired reference") - writeTestFile(t, root, "packages/agent-skills/skills/one-cli/SKILL.md", "current skill") - if err := syncBundled(root); err != nil { - t.Fatalf("syncBundled: %v", err) - } - bundled := filepath.Join(root, "packages", "cli", "internal", "resources", "bundled") - for _, rel := range []string{"registry.json", "_templates/go-api/main.go", "_templates/go-api/_go.mod", "_skills/one-cli/SKILL.md"} { - if _, err := os.Stat(filepath.Join(bundled, filepath.FromSlash(rel))); err != nil { - t.Errorf("expected %s: %v", rel, err) - } - } - for _, rel := range []string{"_templates/registry.json", "_templates/go-api/go.mod", "_skills/one-migrate", "_skills/one-cli/references/old.md"} { - if _, err := os.Stat(filepath.Join(bundled, filepath.FromSlash(rel))); !os.IsNotExist(err) { - t.Errorf("expected %s to be stripped, stat err=%v", rel, err) - } - } - for _, rel := range retiredAssets { - if _, err := os.Stat(filepath.Join(bundled, "_templates", "go-api", filepath.FromSlash(rel))); !os.IsNotExist(err) { - t.Errorf("retired agent asset was bundled: %s (err=%v)", rel, err) - } - } -} - -func TestEnsureGeneratedTargetRejectsBroadOrOutsidePaths(t *testing.T) { - root := t.TempDir() - generated := filepath.Join(root, "packages", "cli", "internal", "resources", "bundled") - if err := ensureGeneratedTarget(root, generated); err == nil { - t.Fatal("expected generated root itself to be rejected") - } - if err := ensureGeneratedTarget(root, filepath.Join(root, "packages")); err == nil { - t.Fatal("expected outside target to be rejected") - } - if err := ensureGeneratedTarget(root, filepath.Join(generated, "_web")); err != nil { - t.Fatalf("expected generated child to be accepted: %v", err) - } -} - -func writeTestFile(t *testing.T, root, rel, content string) { - t.Helper() - path := filepath.Join(root, filepath.FromSlash(rel)) - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { - t.Fatal(err) - } -} - -func TestSyncBundledRejectsReservedNamesBeforeReplacingTemplates(t *testing.T) { - for _, bad := range []string{"_go.mod", "main.go.hbs"} { - t.Run(bad, func(t *testing.T) { - root := t.TempDir() - writeTestFile(t, root, "packages/templates/registry.json", "{}") - writeTestFile(t, root, "packages/templates/go-api/"+bad, "invalid") - writeTestFile(t, root, "packages/cli/internal/resources/bundled/_templates/keep", "previous bundle") - if err := syncBundled(root); err == nil { - t.Fatal("reserved source name accepted") - } - data, err := os.ReadFile(filepath.Join(root, "packages/cli/internal/resources/bundled/_templates/keep")) - if err != nil || string(data) != "previous bundle" { - t.Fatal("replaced previous bundle on validation failure") - } - }) - } -} diff --git a/packages/cli/tools/verify-cli-references/main_test.go b/packages/cli/tools/verify-cli-references/main_test.go deleted file mode 100644 index 03e8fd51..00000000 --- a/packages/cli/tools/verify-cli-references/main_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package main - -import ( - "strings" - "testing" - - "github.com/spf13/cobra" -) - -func TestTaskShorthandReferencesAcrossDocuments(t *testing.T) { - root := &cobra.Command{Use: "one"} - root.AddCommand(&cobra.Command{Use: "run"}, &cobra.Command{Use: "env"}) - commands := collectCommands(root) - documents := map[string][]byte{ - "tasks.md": []byte("`one run dev`, `one run build`, `one run lint`, `one run docs:build`"), - "quick-start.md": []byte("```sh\none dev -- --port 4300\none build\none lint\none docs:build\none env\n```"), - } - valid := collectReferences(commands, documents) - for path, content := range documents { - if problems := scanFile(path, content, valid); len(problems) != 0 { - t.Fatal(problems) - } - } - for _, name := range []string{"dev", "build", "lint", "docs:build"} { - if _, ok := commands[name]; ok { - t.Fatalf("task %q was added to the built-in command catalogue", name) - } - } - problems := scanFile("typo.md", []byte("`one buidl`"), valid) - if len(problems) != 1 || !strings.Contains(problems[0], "buidl") { - t.Fatalf("expected a reference error for undeclared task typo: %v", problems) - } -} diff --git a/packages/cli/tools/verify-help/main.go b/packages/cli/tools/verify-help/main.go index 62ad2246..d5274852 100644 --- a/packages/cli/tools/verify-help/main.go +++ b/packages/cli/tools/verify-help/main.go @@ -1,5 +1,4 @@ -// verify-help refuses structural drift in help text that the snapshot -// tests cannot catch. +// verify-help checks the command catalogue and flags in help examples. // // Run via Taskfile: `task verify-help`. Exits non-zero with one // human-readable line per problem. @@ -21,11 +20,6 @@ // exists. Lines that don't resolve to a real command path are // skipped — prose like `set the --foo flag` outside an example // block isn't validated. -// -// Snapshots in testdata/reference/help/ catch text-level edits; -// verify-help catches the structural-only drifts (rootHelp missing / -// extra command, example using a flag that no longer exists). The two -// complement each other. package main import ( @@ -52,8 +46,6 @@ func main() { fmt.Fprintln(os.Stderr, "\nFix one of:") fmt.Fprintln(os.Stderr, " - Keep root help limited to the curated everyday commands and keep `one help --all` complete.") fmt.Fprintln(os.Stderr, " - Update the Example / Long text in the offending cmd.go to use a flag that actually exists.") - fmt.Fprintln(os.Stderr, " - Re-run with UPDATE_SNAPSHOTS=1 if you have also intentionally changed help text:") - fmt.Fprintln(os.Stderr, " UPDATE_SNAPSHOTS=1 go test ./tests/e2e/ -run TestHelpSnapshots") os.Exit(1) } root := cli.RootCmd() @@ -168,11 +160,7 @@ func checkAllHelp(root *cobra.Command) []string { return problems } -// rootHelpText returns the curated rootHelp constant string. We re- -// render it through the same `one --help` path Execute() uses -// (shouldRenderRootHelp → os.Stdout.WriteString(rootHelp)) by calling -// the package-level test helper. Since rootHelp is an unexported -// constant, we expose it only via cli.RootHelp(); see internal/bootstrap/cli. +// rootHelpText returns the active locale's curated root help through cli.RootHelp. func rootHelpText() string { return cli.RootHelp() } diff --git a/packages/cli/tools/verify-help/main_test.go b/packages/cli/tools/verify-help/main_test.go deleted file mode 100644 index b4913e6c..00000000 --- a/packages/cli/tools/verify-help/main_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package main - -import ( - "strings" - "testing" - - "github.com/spf13/cobra" -) - -func TestRegisteredHelp(t *testing.T) { - if problems := run(); len(problems) != 0 { - t.Fatal(strings.Join(problems, "\n")) - } -} - -func TestExampleFlagsRespectResolvedCommandParsing(t *testing.T) { - root := &cobra.Command{Use: "one"} - hk := &cobra.Command{Use: "hk", DisableFlagParsing: true} - configure := &cobra.Command{Use: "configure"} - configure.Flags().Bool("dry-run", false, "") - run := &cobra.Command{Use: "run [task]"} - run.Flags().String("project", "", "") - root.AddCommand(hk, configure, run) - for _, tt := range []struct { - name, example string - owner *cobra.Command - wantProblems int - }{ - {"dev shorthand", "one dev --project web", run, 0}, - {"build shorthand", "one build --project web", run, 0}, - {"test shorthand", "one test --project web", run, 0}, - {"lint shorthand", "one lint --project web", run, 0}, - {"namespaced shorthand", "one docs:build --project docs", run, 0}, - {"unknown task flag", "one dev --typo", run, 1}, - {"task arguments", "one dev --project web -- --port 4300", run, 0}, - {"built-in precedence", "one configure --project web", run, 1}, - {"native flag", "one configure --dry-run", configure, 0}, - {"unknown native flag", "one configure --typo", configure, 1}, - {"forwarded flag", "one hk check --all", hk, 0}, - {"forwarded flag in another command's help", "one hk check --all", configure, 0}, - {"native typo in passthrough help", "one configure --typo", hk, 1}, - } { - t.Run(tt.name, func(t *testing.T) { - problems := scanInvocations(root, tt.owner, "Example", tt.example) - if len(problems) != tt.wantProblems { - t.Fatalf("problems = %v, want %d", problems, tt.wantProblems) - } - }) - } -} diff --git a/packages/kernel/Taskfile.yml b/packages/kernel/Taskfile.yml index d2154db7..ebebdb39 100644 --- a/packages/kernel/Taskfile.yml +++ b/packages/kernel/Taskfile.yml @@ -4,10 +4,6 @@ tasks: cmds: ['go build ./...'] check: cmds: ['go vet ./...'] - test: - cmds: ['go test ./... {{.CLI_ARGS}}'] - test:race: - cmds: ['go test -race ./... {{.CLI_ARGS}}'] fmt: cmds: ['go fmt ./...'] tidy: diff --git a/packages/kernel/pkg/kernel/architecture_test.go b/packages/kernel/pkg/kernel/architecture_test.go deleted file mode 100644 index 8f2472a2..00000000 --- a/packages/kernel/pkg/kernel/architecture_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package kernel - -import ( - "go/parser" - "go/token" - "io/fs" - "path/filepath" - "runtime" - "strconv" - "strings" - "testing" -) - -// TestKernelImportsOnlyStandardLibraryAndItself keeps the reusable runtime -// independent from CLI domains, adapters, and vendor packages. -func TestKernelImportsOnlyStandardLibraryAndItself(t *testing.T) { - t.Parallel() - - _, currentFile, _, ok := runtime.Caller(0) - if !ok { - t.Fatal("resolve kernel source path") - } - root := filepath.Clean(filepath.Join(filepath.Dir(currentFile), "..", "..")) - err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - if entry.IsDir() || filepath.Ext(path) != ".go" || strings.HasSuffix(path, "_test.go") { - return nil - } - parsed, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly) - if err != nil { - return err - } - for _, imported := range parsed.Imports { - importPath, err := strconv.Unquote(imported.Path.Value) - if err != nil { - continue - } - if strings.HasPrefix(importPath, "github.com/torchstellar-team/one-cli/packages/kernel/") { - continue - } - firstSegment, _, _ := strings.Cut(importPath, "/") - if strings.Contains(firstSegment, ".") { - t.Errorf("%s imports non-standard package %s", filepath.Base(path), importPath) - } - } - return nil - }) - if err != nil { - t.Fatal(err) - } -} diff --git a/packages/kernel/pkg/kernel/context_test.go b/packages/kernel/pkg/kernel/context_test.go deleted file mode 100644 index a1e187f5..00000000 --- a/packages/kernel/pkg/kernel/context_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package kernel - -import ( - "context" - "testing" -) - -func TestContextDeriveSharesLifecycle(t *testing.T) { - t.Parallel() - - type key struct{} - parent := NewContext(context.WithValue(context.Background(), key{}, "parent")) - child := parent.Derive(context.WithValue(parent.Context(), key{}, "child")) - - if child.Lifecycle() != parent.Lifecycle() { - t.Fatal("derived context did not share lifecycle") - } - if got := parent.Context().Value(key{}); got != "parent" { - t.Fatalf("parent context value = %v", got) - } - if got := child.Context().Value(key{}); got != "child" { - t.Fatalf("child context value = %v", got) - } -} - -func TestZeroContextIsSafe(t *testing.T) { - t.Parallel() - - var runtime Context - if runtime.Context() == nil { - t.Fatal("zero Context returned nil standard context") - } - if err := runtime.Close(context.Background()); err != nil { - t.Fatalf("zero Context Close() = %v", err) - } -} diff --git a/packages/kernel/pkg/kernel/lifecycle_test.go b/packages/kernel/pkg/kernel/lifecycle_test.go deleted file mode 100644 index 413a4ca3..00000000 --- a/packages/kernel/pkg/kernel/lifecycle_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package kernel - -import ( - "context" - "errors" - "reflect" - "testing" -) - -func TestLifecycleClosesLIFOOnceAndJoinsErrors(t *testing.T) { - t.Parallel() - - lifecycle := NewLifecycle() - order := []string{} - cleanupErr := errors.New("failed") - if err := lifecycle.Defer("first", func(context.Context) error { - order = append(order, "first") - return nil - }); err != nil { - t.Fatal(err) - } - if err := lifecycle.Defer("second", func(context.Context) error { - order = append(order, "second") - return cleanupErr - }); err != nil { - t.Fatal(err) - } - - err := lifecycle.Close(context.Background()) - if !errors.Is(err, cleanupErr) || err.Error() != "second: failed" { - t.Fatalf("Close() error = %v", err) - } - if !reflect.DeepEqual(order, []string{"second", "first"}) { - t.Fatalf("cleanup order = %#v", order) - } - if err := lifecycle.Close(context.Background()); !errors.Is(err, cleanupErr) { - t.Fatalf("second Close() = %v", err) - } - if !reflect.DeepEqual(order, []string{"second", "first"}) { - t.Fatalf("cleanup ran twice: %#v", order) - } - if err := lifecycle.Defer("late", func(context.Context) error { return nil }); !errors.Is(err, ErrLifecycleClosed) { - t.Fatalf("late Defer() = %v", err) - } -} - -func TestLifecycleRejectsNilCleanup(t *testing.T) { - t.Parallel() - - if err := NewLifecycle().Defer("nil", nil); !errors.Is(err, ErrNilCleanup) { - t.Fatalf("Defer(nil) = %v", err) - } -} diff --git a/packages/templates/electron-app/.oxlintrc.json b/packages/templates/electron-app/.oxlintrc.json index 5794e4a3..344985b3 100644 --- a/packages/templates/electron-app/.oxlintrc.json +++ b/packages/templates/electron-app/.oxlintrc.json @@ -10,8 +10,7 @@ "**/node_modules/**", "**/dist/**", "**/build/**", - "**/.vite/**", - "**/coverage/**" + "**/.vite/**" ], "rules": { "no-console": "off", diff --git a/packages/templates/electron-app/package.json b/packages/templates/electron-app/package.json index b48dfbc5..692f3b9f 100644 --- a/packages/templates/electron-app/package.json +++ b/packages/templates/electron-app/package.json @@ -14,7 +14,6 @@ "scripts": { "dev": "pnpm -F @one-template-electron/preload build && pnpm -r --parallel -F @one-template-electron/main -F @one-template-electron/renderer -F @one-template-electron/preload run dev", "build": "pnpm --filter @one-template-electron/main... run build", - "test": "pnpm -F @one-template-electron/preload run build && pnpm -F @one-template-electron/main run test", "pack": "pnpm run build && pnpm -F @one-template-electron/main run pack", "sandbox:profile": "pnpm --silent -F @one-template-electron/main run sandbox:profile", "release": "pnpm run build && pnpm -F @one-template-electron/main run release", diff --git a/packages/templates/electron-app/services/one-template-electron-main/package.json b/packages/templates/electron-app/services/one-template-electron-main/package.json index 1cb99e3d..23ed3865 100644 --- a/packages/templates/electron-app/services/one-template-electron-main/package.json +++ b/packages/templates/electron-app/services/one-template-electron-main/package.json @@ -10,7 +10,6 @@ "dev": "node scripts/dev.mjs", "build": "tsc --noEmit && cross-env NODE_ENV=production tsdown", "typecheck": "tsc --noEmit", - "test": "vitest run", "sandbox:profile": "node scripts/sandbox-profile.mjs", "pack": "cross-env NODE_ENV=development node scripts/release.mjs", "release": "cross-env NODE_ENV=production node scripts/release.mjs", @@ -34,7 +33,6 @@ "oxfmt": "^0.71.0", "oxlint": "^1.86.0", "tsdown": "^0.23.0", - "typescript": "^7.0.2", - "vitest": "^5.0.2" + "typescript": "^7.0.2" } } diff --git a/packages/templates/electron-app/services/one-template-electron-main/test/dev-environment.test.mjs b/packages/templates/electron-app/services/one-template-electron-main/test/dev-environment.test.mjs deleted file mode 100644 index 07480b42..00000000 --- a/packages/templates/electron-app/services/one-template-electron-main/test/dev-environment.test.mjs +++ /dev/null @@ -1,444 +0,0 @@ -import * as fs from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - detectDesktopEnvironment, - readSessionEnvironment, -} from "../scripts/desktop-env.mjs"; -import { developmentMessages, messages } from "../scripts/dev-messages.mjs"; -import { - sandboxProfile, - sandboxProfileName, -} from "../scripts/sandbox-profile.mjs"; -import { - checkUbuntuSandbox, - isUbuntu, - probeUserNamespace, - shellQuote, -} from "../scripts/ubuntu-sandbox.mjs"; - -const cleanup = []; -afterEach(async () => { - for (const dispose of cleanup.splice(0).toReversed()) await dispose(); -}); - -async function temporary() { - const root = await fs.mkdtemp(path.join(tmpdir(), "one-desktop-")); - cleanup.push(() => fs.rm(root, { recursive: true, force: true })); - return root; -} - -function sandboxOptions({ - release = 'ID="ubuntu"\nID_LIKE=debian', - restricted = "1", - clone = "1", - helper, -} = {}) { - const settings = { - "/etc/os-release": release, - "/proc/sys/kernel/apparmor_restrict_unprivileged_userns": restricted, - "/proc/sys/kernel/unprivileged_userns_clone": clone, - }; - return { - binary: "/workspace/node_modules/electron/dist/electron", - platform: "linux", - env: { LANG: "C" }, - probe: vi.fn(() => ({ status: 0 })), - io: { - ...fs, - readFile: vi.fn(async (file) => { - if (!(file in settings)) throw new Error("missing"); - return settings[file]; - }), - stat: vi.fn(async () => { - if (!helper) throw new Error("missing"); - return { isFile: () => true, uid: 0, mode: 0o104755, ...helper }; - }), - mkdtemp: vi.fn(fs.mkdtemp), - }, - }; -} - -describe("Ubuntu sandbox preflight", () => { - it("matches Ubuntu's ID rather than derivatives, descriptions, or shell expressions", () => { - for (const release of ["ID=ubuntu\n", 'ID="ubuntu"', "ID='ubuntu'"]) - expect(isUbuntu(release)).toBe(true); - for (const release of [ - 'ID=linuxmint\nID_LIKE="ubuntu debian"', - 'NAME="Ubuntu"\nID=debian', - "ID=$(echo ubuntu)", - "ID=ubuntu-test", - ]) - expect(isUbuntu(release)).toBe(false); - }); - it.each(["darwin", "win32"])( - "never accesses Linux configuration on %s", - async (platform) => { - const options = sandboxOptions(); - await checkUbuntuSandbox({ ...options, platform }); - expect(options.io.readFile).not.toHaveBeenCalled(); - expect(options.probe).not.toHaveBeenCalled(); - }, - ); - it("skips non-Ubuntu systems and kernels without the AppArmor restriction", async () => { - for (const settings of [ - { release: "ID=fedora\nID_LIKE=ubuntu" }, - { restricted: "0" }, - { restricted: undefined }, - ]) { - const options = sandboxOptions(settings); - if (settings.restricted === undefined && "restricted" in settings) - options.io.readFile.mockImplementation(async (file) => { - if (file === "/etc/os-release") return "ID=ubuntu"; - throw new Error("missing"); - }); - await checkUbuntuSandbox(options); - expect(options.probe).not.toHaveBeenCalled(); - expect(options.io.mkdtemp).not.toHaveBeenCalled(); - } - }); - it("accepts a correctly installed SUID helper but rejects user-owned or writable helpers", async () => { - const options = sandboxOptions({ helper: {} }); - await checkUbuntuSandbox(options); - expect(options.probe).not.toHaveBeenCalled(); - for (const helper of [ - { uid: 1000 }, - { mode: 0o104777 }, - { mode: 0o100755 }, - ]) { - const unsafe = sandboxOptions({ helper }); - await checkUbuntuSandbox(unsafe); - expect(unsafe.probe).toHaveBeenCalledOnce(); - } - }); - it("checks actual permissions each time without generating profiles once configured", async () => { - const options = sandboxOptions(); - await checkUbuntuSandbox(options); - await checkUbuntuSandbox(options); - expect(options.probe).toHaveBeenCalledTimes(2); - expect(options.probe).toHaveBeenCalledWith(options.binary, options.env); - expect(options.io.mkdtemp).not.toHaveBeenCalled(); - }); - it.each(["en-US", "zh-CN"])( - "prepares a private exact-binary profile and manual installation guidance in %s", - async (locale) => { - const root = await temporary(); - const options = sandboxOptions(); - options.binary = "/workspace/a 'quoted' [project]/electron"; - options.probe.mockReturnValue({ - status: 1, - stderr: "unshare: Operation not permitted", - }); - let failure; - try { - await checkUbuntuSandbox({ - ...options, - text: messages[locale], - temporaryDirectory: root, - }); - } catch (error) { - failure = error; - } - const [directory] = await fs.readdir(root); - const profile = path.join( - root, - directory, - sandboxProfileName(options.binary), - ); - expect(await fs.readFile(profile, "utf8")).toBe( - sandboxProfile(options.binary), - ); - expect((await fs.stat(profile)).mode & 0o777).toBe(0o600); - expect(failure.message).toContain(profile); - expect(failure.message).toContain( - "sudo install -o root -g root -m 644 --", - ); - expect(failure.message).toContain("sudo apparmor_parser -r"); - expect(failure.message).toContain( - locale === "zh-CN" ? "重新运行开发任务" : "rerun the development task", - ); - expect(failure.message).not.toContain("--no-sandbox"); - expect(options.probe).toHaveBeenCalledOnce(); - }, - ); - it("does not misdiagnose disabled namespaces or a failed probe as missing AppArmor configuration", async () => { - const disabled = sandboxOptions({ clone: "0" }); - await expect(checkUbuntuSandbox(disabled)).rejects.toThrow( - "disabled unprivileged", - ); - expect(disabled.probe).not.toHaveBeenCalled(); - const options = sandboxOptions(); - options.probe.mockReturnValue({ status: null, error: "unshare ENOENT" }); - await expect(checkUbuntuSandbox(options)).rejects.toThrow("unshare ENOENT"); - options.probe.mockImplementation(() => { - throw new Error("Electron ENOENT"); - }); - await expect(checkUbuntuSandbox(options)).rejects.toThrow( - "Electron ENOENT", - ); - expect(options.io.mkdtemp).not.toHaveBeenCalled(); - }); - it("uses Electron's profile for the namespace probe without opening a GUI", () => { - const run = vi.fn(() => ({ - status: 0, - stdout: '{"status":0}', - stderr: "", - })); - expect( - probeUserNamespace("/path/electron", { TEST: "preserved" }, run), - ).toEqual({ status: 0 }); - const [binary, args, options] = run.mock.calls[0]; - expect(binary).toBe("/path/electron"); - expect(args[0]).toBe("--eval"); - expect(args[1]).toContain("'/usr/bin/unshare'"); - expect(args[1]).toContain("'--mount'"); - expect(options.env).toEqual({ - TEST: "preserved", - ELECTRON_RUN_AS_NODE: "1", - }); - expect(options.shell).toBeUndefined(); - expect(shellQuote("/tmp/a'b $HOME `command`")).toBe( - "'/tmp/a'\\''b $HOME `command`'", - ); - }); -}); - -async function desktop() { - const root = await temporary(); - const uid = process.getuid?.() ?? 1000; - const runtimeBase = path.join(root, "users"); - const runtime = path.join(runtimeBase, String(uid)); - const home = path.join(root, "home"); - const x11Directory = path.join(root, "x11"); - for (const dir of [runtime, home, x11Directory]) - await fs.mkdir(dir, { recursive: true }); - const sockets = new Map(); - const options = { - env: {}, - platform: "linux", - uid, - home, - runtimeBase, - x11Directory, - sessionEnvironment: vi.fn(() => ({})), - log: vi.fn(), - io: { - ...fs, - stat: async (file) => { - const info = await fs.stat(file); - info.uid = uid; - if (sockets.has(file)) info.isSocket = () => true; - return info; - }, - }, - connect: vi.fn(async (file) => sockets.get(file) === true), - }; - const socket = async (file, active = true) => { - await fs.writeFile(file, "socket fixture"); - sockets.set(file, active); - }; - return { options, runtime, home, x11Directory, socket }; -} - -describe("desktop discovery", () => { - it("queries only the current user's bus and retains only display variables", () => { - const run = vi.fn(() => ({ - status: 0, - stdout: - "DISPLAY=:0\nWAYLAND_DISPLAY=wayland-0\nXAUTHORITY=/run/user/42/auth\nSECRET=private\n", - })); - expect(readSessionEnvironment({}, "/run/user/42", run)).toEqual({ - DISPLAY: ":0", - WAYLAND_DISPLAY: "wayland-0", - XAUTHORITY: "/run/user/42/auth", - }); - expect(run.mock.calls[0][2].env.DBUS_SESSION_BUS_ADDRESS).toBe( - "unix:path=/run/user/42/bus", - ); - run.mockReturnValue({ status: 1 }); - expect(readSessionEnvironment({}, "/run/user/42", run)).toEqual({}); - }); - - it.each(["darwin", "win32"])( - "preserves all variables and skips discovery on %s", - async (platform) => { - const io = { stat: vi.fn() }; - const env = { TEST: "preserved" }; - expect(await detectDesktopEnvironment({ env, platform, io })).toEqual( - env, - ); - expect(io.stat).not.toHaveBeenCalled(); - }, - ); - it.each(["localhost:10.0", ":99", ":0"])( - "preserves explicit display %s including SSH forwarding and Xvfb", - async (DISPLAY) => { - const sessionEnvironment = vi.fn(); - const env = { - DISPLAY, - XAUTHORITY: "/forwarded/auth", - XDG_SESSION_TYPE: "x11", - WAYLAND_DISPLAY: "stale-wayland", - SSH_CONNECTION: "remote", - OTHER: "value", - }; - expect( - await detectDesktopEnvironment({ - env, - platform: "linux", - sessionEnvironment, - }), - ).toEqual(env); - expect(sessionEnvironment).not.toHaveBeenCalled(); - }, - ); - it("recovers a live current-user Wayland display and tells SSH users where windows appear", async () => { - const fixture = await desktop(); - await fixture.socket(path.join(fixture.runtime, "wayland-3")); - fixture.options.env = { SSH_CONNECTION: "remote", OTHER: "preserved" }; - const result = await detectDesktopEnvironment(fixture.options); - expect(result).toMatchObject({ - WAYLAND_DISPLAY: "wayland-3", - XDG_RUNTIME_DIR: fixture.runtime, - XDG_SESSION_TYPE: "wayland", - OTHER: "preserved", - }); - expect(fixture.options.env).not.toHaveProperty("WAYLAND_DISPLAY"); - expect(fixture.options.log).toHaveBeenCalledWith( - expect.stringContaining("windows will appear on that desktop"), - ); - }); - it("fills runtime and session type for an explicit Wayland display", async () => { - const fixture = await desktop(); - const socket = path.join(fixture.runtime, "wayland-2"); - await fixture.socket(socket); - fixture.options.env = { WAYLAND_DISPLAY: socket }; - expect(await detectDesktopEnvironment(fixture.options)).toMatchObject({ - WAYLAND_DISPLAY: socket, - XDG_SESSION_TYPE: "wayland", - XDG_RUNTIME_DIR: fixture.runtime, - }); - expect(fixture.options.log).not.toHaveBeenCalled(); - }); - it("chooses the service manager's live Wayland display when multiple sockets exist", async () => { - const fixture = await desktop(); - for (const name of ["wayland-0", "wayland-1"]) - await fixture.socket(path.join(fixture.runtime, name)); - fixture.options.sessionEnvironment.mockReturnValue({ - WAYLAND_DISPLAY: "wayland-1", - SECRET: "never copy", - }); - const result = await detectDesktopEnvironment(fixture.options); - expect(result.WAYLAND_DISPLAY).toBe("wayland-1"); - expect(result).not.toHaveProperty("SECRET"); - fixture.options.sessionEnvironment.mockReturnValue({}); - await expect(detectDesktopEnvironment(fixture.options)).rejects.toThrow( - "Set WAYLAND_DISPLAY", - ); - }); - it("ignores lock files, regular files, and stale display sockets", async () => { - const fixture = await desktop(); - await fs.writeFile(path.join(fixture.runtime, "wayland-0"), "not a socket"); - await fs.writeFile(path.join(fixture.runtime, "wayland-1.lock"), "lock"); - await fixture.socket(path.join(fixture.runtime, "wayland-2"), false); - await expect(detectDesktopEnvironment(fixture.options)).rejects.toThrow( - "No desktop display", - ); - fixture.options.env = { WAYLAND_DISPLAY: "wayland-0" }; - await expect(detectDesktopEnvironment(fixture.options)).rejects.toThrow( - "Wayland display is unavailable", - ); - }); - it("rejects runtime directories and sockets owned by another user", async () => { - const fixture = await desktop(); - await fixture.socket(path.join(fixture.runtime, "wayland-0")); - fixture.options.env = { XDG_RUNTIME_DIR: fixture.runtime }; - fixture.options.uid++; - await expect(detectDesktopEnvironment(fixture.options)).rejects.toThrow( - "belongs to another user", - ); - fixture.options.uid--; - const io = fixture.options.io; - fixture.options.io = { - ...io, - stat: async (file) => { - const info = await io.stat(file); - if (file.endsWith("wayland-0")) info.uid++; - return info; - }, - }; - await expect(detectDesktopEnvironment(fixture.options)).rejects.toThrow( - "No desktop display", - ); - }); - it("recovers X11 with the session's current-user authentication file", async () => { - const fixture = await desktop(); - const auth = path.join(fixture.runtime, "session-auth"); - await fs.writeFile(auth, "cookie"); - await fixture.socket(path.join(fixture.x11Directory, "X2")); - fixture.options.sessionEnvironment.mockReturnValue({ - DISPLAY: ":2.0", - XAUTHORITY: auth, - }); - expect(await detectDesktopEnvironment(fixture.options)).toMatchObject({ - DISPLAY: ":2.0", - XAUTHORITY: auth, - XDG_SESSION_TYPE: "x11", - }); - }); - it("uses a unique X11 socket with .Xauthority, and refuses ambiguous or unauthenticated displays", async () => { - const fixture = await desktop(); - const auth = path.join(fixture.home, ".Xauthority"); - await fixture.socket(path.join(fixture.x11Directory, "X7")); - await expect(detectDesktopEnvironment(fixture.options)).rejects.toThrow( - "No desktop display", - ); - await fs.writeFile(auth, "cookie"); - expect(await detectDesktopEnvironment(fixture.options)).toMatchObject({ - DISPLAY: ":7", - XAUTHORITY: auth, - }); - await fixture.socket(path.join(fixture.x11Directory, "X8")); - await expect(detectDesktopEnvironment(fixture.options)).rejects.toThrow( - "Set DISPLAY", - ); - }); -}); - -describe("development guidance languages", () => { - it("keeps both catalogs and parameter counts in sync", () => { - expect(Object.keys(messages["zh-CN"]).toSorted()).toEqual( - Object.keys(messages["en-US"]).toSorted(), - ); - for (const key of Object.keys(messages["en-US"])) { - expect(typeof messages["zh-CN"][key]).toBe(typeof messages["en-US"][key]); - expect(messages["zh-CN"][key].length).toBeGreaterThan(0); - if (typeof messages["en-US"][key] === "function") - expect(messages["zh-CN"][key].length).toBe( - messages["en-US"][key].length, - ); - } - }); - it("reads One's existing language preference each time and honors the auto-locale order", async () => { - const config = await temporary(); - await fs.mkdir(path.join(config, "one")); - const env = { - XDG_CONFIG_HOME: config, - LC_ALL: "zh_CN.UTF-8", - LC_MESSAGES: "en_US.UTF-8", - }; - expect(await developmentMessages(env)).toBe(messages["zh-CN"]); - const file = path.join(config, "one/preferences.json"); - await fs.writeFile(file, JSON.stringify({ locale: "en-US" })); - expect(await developmentMessages(env)).toBe(messages["en-US"]); - await fs.writeFile(file, JSON.stringify({ locale: "zh-CN" })); - expect(await developmentMessages({ ...env, LC_ALL: "C" })).toBe( - messages["zh-CN"], - ); - await fs.writeFile(file, JSON.stringify({ locale: "auto" })); - expect(await developmentMessages({ ...env, LC_ALL: "C" })).toBe( - messages["en-US"], - ); - }); -}); diff --git a/packages/templates/electron-app/services/one-template-electron-main/test/main.test.ts b/packages/templates/electron-app/services/one-template-electron-main/test/main.test.ts deleted file mode 100644 index d01b16de..00000000 --- a/packages/templates/electron-app/services/one-template-electron-main/test/main.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { EventEmitter } from "node:events"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { IpcMainInvokeEvent } from "electron"; -import { createMainWindow } from "../src/windows/main.window"; -import { createAppIPC } from "../src/ipc"; -import { createMainContainer } from "../src/container"; -import { IPC } from "../src/types/events"; - -const mocks = vi.hoisted(() => ({ - handles: new Map unknown>(), - removeHandler: vi.fn(), - expose: vi.fn(), - invoke: vi.fn(), - log: { info: vi.fn(), error: vi.fn() }, -})); -vi.mock("../src/utils", () => ({ - preloadPath: "/app/preload/index.cjs", - rendererPath: "/app with spaces/renderer/index.html", -})); -vi.mock("electron-log/main", () => ({ default: mocks.log })); -vi.mock("electron", async () => { - const { EventEmitter } = await import("node:events"); - return { - app: Object.assign(new EventEmitter(), { - getName: () => "desktop", - getVersion: () => "0.1.0", - quit: vi.fn(), - }), - ipcMain: { - handle: (channel: string, handler: (...args: unknown[]) => unknown) => - mocks.handles.set(channel, handler), - removeHandler: mocks.removeHandler, - }, - contextBridge: { exposeInMainWorld: mocks.expose }, - ipcRenderer: { invoke: mocks.invoke }, - BrowserWindow: class extends EventEmitter { - webContents = Object.assign(new EventEmitter(), { - mainFrame: { url: "file:///app%20with%20spaces/renderer/index.html" }, - setWindowOpenHandler: vi.fn(), - }); - loadURL = vi.fn(async () => {}); - loadFile = vi.fn(async () => {}); - show = vi.fn(); - focus = vi.fn(); - restore = vi.fn(); - isMinimized = vi.fn(() => true); - isDestroyed = vi.fn(() => false); - destroy = vi.fn(); - }, - }; -}); -beforeEach(() => { - vi.clearAllMocks(); - mocks.handles.clear(); - vi.stubEnv("NODE_ENV", "production"); -}); - -describe("main window", () => { - it("loads the production HTML file, protects navigation, and reuses its window", async () => { - const service = createMainWindow(); - await service.init(); - const window = service.window!; - expect(window.loadFile).toHaveBeenCalledWith( - "/app with spaces/renderer/index.html", - ); - expect(window.loadURL).not.toHaveBeenCalled(); - const preventDefault = vi.fn(); - (window.webContents as unknown as EventEmitter).emit("will-navigate", { - preventDefault, - }); - expect(preventDefault).toHaveBeenCalledOnce(); - const handler = vi.mocked(window.webContents.setWindowOpenHandler).mock - .calls[0][0]; - expect(handler({} as never)).toEqual({ action: "deny" }); - await service.init(); - expect(window.restore).toHaveBeenCalledOnce(); - expect(window.focus).toHaveBeenCalledOnce(); - expect(service.window).toBe(window); - (window as unknown as EventEmitter).emit("closed"); - expect(service.window).toBeNull(); - }); - it("loads Vite in development", async () => { - vi.stubEnv("NODE_ENV", "development"); - const service = createMainWindow(); - await service.init(); - expect(service.window!.loadURL).toHaveBeenCalledWith( - "http://localhost:5173/", - ); - expect(service.window!.loadFile).not.toHaveBeenCalled(); - }); -}); -describe("isolated app information bridge", () => { - it("accepts only its window's trusted main frame and no arguments", async () => { - const mainWindow = createMainWindow(); - await mainWindow.init(); - const ipc = createAppIPC({ mainWindow }); - ipc.init(); - const contents = mainWindow.window!.webContents; - const event = { - sender: contents, - senderFrame: contents.mainFrame, - } as IpcMainInvokeEvent; - const handler = mocks.handles.get(IPC.appInfo)!; - expect(handler(event)).toEqual({ - name: "desktop", - version: "0.1.0", - platform: process.platform, - }); - for (const bad of [ - { ...event, sender: {} }, - { ...event, senderFrame: { url: contents.mainFrame.url } }, - { ...event, senderFrame: null }, - ]) - expect(() => handler(bad)).toThrow("Unauthorized"); - expect(() => handler(event, "extra")).toThrow("Unauthorized"); - const frame = contents.mainFrame as unknown as { url: string }; - frame.url = "https://untrusted.example/"; - expect(() => handler(event)).toThrow("Unauthorized"); - ipc.dispose(); - expect(mocks.removeHandler).toHaveBeenCalledWith(IPC.appInfo); - }); - it("exposes a fixed API without raw channel access", async () => { - const { createRequire } = await import("node:module"); - const preloadPackage = "@one-template-electron/preload"; - const entry = createRequire(import.meta.url).resolve(preloadPackage); - await import(entry.replace(/dist[/\\]index\.cjs$/, "src/index.ts")); - const [name, api] = mocks.expose.mock.calls[0]; - expect(name).toBe("electron"); - expect(Object.keys(api)).toEqual(["getAppInfo"]); - await api.getAppInfo(); - expect(mocks.invoke).toHaveBeenCalledWith(IPC.appInfo); - }); - it("disposes IPC and lifecycle hooks through Awilix", async () => { - const { app } = await import("electron"); - const container = createMainContainer(); - const application = container.resolve("application"); - await application.init(); - expect(app.listenerCount("activate")).toBe(1); - await container.dispose(); - expect(app.listenerCount("activate")).toBe(0); - expect(app.listenerCount("second-instance")).toBe(0); - expect(mocks.removeHandler).toHaveBeenCalledWith(IPC.appInfo); - }); -}); diff --git a/packages/templates/electron-app/services/one-template-electron-main/test/scripts.test.mjs b/packages/templates/electron-app/services/one-template-electron-main/test/scripts.test.mjs deleted file mode 100644 index 753ad555..00000000 --- a/packages/templates/electron-app/services/one-template-electron-main/test/scripts.test.mjs +++ /dev/null @@ -1,117 +0,0 @@ -import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; -import { createServer } from "node:http"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { waitForRenderer } from "../scripts/dev.mjs"; -import { prepareResources } from "../scripts/resources.mjs"; - -const cleanup = []; -afterEach(async () => { - await Promise.all(cleanup.splice(0).map((dispose) => dispose())); -}); - -async function rendererServer() { - let ready = false; - let requests = 0; - const server = createServer((_request, response) => { - requests++; - response.writeHead(ready ? 200 : 503); - response.end(); - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - cleanup.push(() => new Promise((resolve) => server.close(resolve))); - return { - url: `http://127.0.0.1:${server.address().port}/`, - setReady: () => { - ready = true; - }, - requests: () => requests, - }; -} - -describe("desktop development", () => { - it("waits through unavailable responses before launching main", async () => { - const server = await rendererServer(); - const timer = setTimeout(server.setReady, 150); - try { - await waitForRenderer(server.url); - expect(server.requests()).toBeGreaterThan(1); - } finally { - clearTimeout(timer); - } - }); - it("reports an unavailable renderer and honors cancellation", async () => { - const server = await rendererServer(); - await expect(waitForRenderer(server.url, { timeout: 100 })).rejects.toThrow( - server.url, - ); - const controller = new AbortController(); - const pending = waitForRenderer(server.url, { signal: controller.signal }); - controller.abort(); - await expect(pending).rejects.toThrow(); - }); -}); - -describe("desktop packaging resources", () => { - async function fixture() { - const root = await mkdtemp(path.join(tmpdir(), "electron resources ")); - cleanup.push(() => rm(root, { recursive: true, force: true })); - const mainDir = path.join(root, "services/desktop-main"); - const rendererDir = path.join(root, "apps/desktop-renderer/dist"); - const preloadEntry = path.join( - root, - "packages/desktop-preload/dist/index.cjs", - ); - for (const dir of [ - path.join(mainDir, "build/main"), - path.join(rendererDir, "assets"), - path.dirname(preloadEntry), - ]) { - await mkdir(dir, { recursive: true }); - } - await writeFile(path.join(mainDir, "build/main/index.js"), "main"); - await writeFile(path.join(rendererDir, "index.html"), "renderer"); - await writeFile( - path.join(rendererDir, "assets/icon.bin"), - Buffer.from([0, 255, 12]), - ); - await writeFile(preloadEntry, "bridge"); - return { mainDir, rendererDir, preloadEntry }; - } - it("assembles isolated copies and removes stale renderer assets", async () => { - const files = await fixture(); - await prepareResources(files); - const stale = path.join(files.mainDir, "build/renderer/stale.js"); - await writeFile(stale, "stale"); - await prepareResources(files); - await expect(readFile(stale)).rejects.toThrow(); - expect( - await readFile( - path.join(files.mainDir, "build/renderer/assets/icon.bin"), - ), - ).toEqual(Buffer.from([0, 255, 12])); - expect( - await readFile( - path.join(files.mainDir, "build/preload/index.cjs"), - "utf8", - ), - ).toBe("bridge"); - expect( - await readFile(path.join(files.rendererDir, "index.html"), "utf8"), - ).toBe("renderer"); - expect(await readFile(files.preloadEntry, "utf8")).toBe("bridge"); - }); - it("keeps the existing assembly when an input build is missing", async () => { - const files = await fixture(); - await prepareResources(files); - await rm(files.preloadEntry); - await expect(prepareResources(files)).rejects.toThrow(); - expect( - await readFile( - path.join(files.mainDir, "build/renderer/index.html"), - "utf8", - ), - ).toBe("renderer"); - }); -}); diff --git a/packages/templates/electron-app/services/one-template-electron-main/tsconfig.json b/packages/templates/electron-app/services/one-template-electron-main/tsconfig.json index 3eef0bbf..3bd6f841 100644 --- a/packages/templates/electron-app/services/one-template-electron-main/tsconfig.json +++ b/packages/templates/electron-app/services/one-template-electron-main/tsconfig.json @@ -9,7 +9,12 @@ "resolveJsonModule": true, "forceConsistentCasingInFileNames": true, "outDir": "build/main", - "types": ["node", "electron"] + "types": [ + "node", + "electron" + ] }, - "include": ["./src/**/*", "./test/**/*"] + "include": [ + "./src/**/*" + ] } diff --git a/packages/templates/electron-app/template.json b/packages/templates/electron-app/template.json index e06257f7..d8e49219 100644 --- a/packages/templates/electron-app/template.json +++ b/packages/templates/electron-app/template.json @@ -7,8 +7,7 @@ "services/one-template-electron-main/src/types/events.ts", "apps/one-template-electron-renderer/src/nodes/HomePage/index.tsx", "apps/one-template-electron-renderer/src/renderer.d.ts", - "services/one-template-electron-main/scripts/release.mjs", - "services/one-template-electron-main/test/main.test.ts" + "services/one-template-electron-main/scripts/release.mjs" ] }, "text": [ diff --git a/packages/templates/expo-mobile/README.md b/packages/templates/expo-mobile/README.md index dbbc7c2a..683fd757 100644 --- a/packages/templates/expo-mobile/README.md +++ b/packages/templates/expo-mobile/README.md @@ -6,7 +6,7 @@ Expo · Expo Router · React Native · TypeScript · Axios · SWR · Zustand · - `pnpm android` / `pnpm ios`:构建原生开发客户端;`pnpm start`:启动开发服务器;`pnpm web`:浏览器开发。 - MMKV 依赖 Nitro 原生模块,需要开发客户端,不能用 Expo Go 验证。 -- `pnpm check`、`pnpm test` 检查类型、格式与原生网络/前后台监听生命周期。 +- `pnpm check` 检查类型、代码与格式。 - 只有首页、404 和主题切换。使用 React Native 样式,系统主题为默认值;MMKV 只保存 UI 偏好。 - Axios 接收 `EXPO_PUBLIC_API_URL`。SWR 管理远程缓存,Zustand 管理共享 UI;没有 token、登录或自定义远程缓存。 - 通过 `pnpm exec expo install --check` 检查 SDK 依赖兼容性,按需手动安装业务 skills。 @@ -15,7 +15,7 @@ Expo · Expo Router · React Native · TypeScript · Axios · SWR · Zustand · - `pnpm android` / `pnpm ios`: build native development clients; `pnpm start`: start the development server; `pnpm web`: browser development. - MMKV requires Nitro native modules and a development client. Expo Go cannot validate it. -- `pnpm check` and `pnpm test` validate types, formatting, and network/foreground listener lifecycles. +- `pnpm check` validates types, code, and formatting. - Includes only home, 404, and theme switching. Uses React Native styles, defaults to the system theme, and stores UI preferences in MMKV. - Axios reads `EXPO_PUBLIC_API_URL`. SWR owns remote caching; Zustand owns shared UI. No token, login, or custom remote cache is included. - Check SDK compatibility with `pnpm exec expo install --check`. Install business skills manually as needed. diff --git a/packages/templates/expo-mobile/jest.config.cjs b/packages/templates/expo-mobile/jest.config.cjs deleted file mode 100644 index 1ab94d90..00000000 --- a/packages/templates/expo-mobile/jest.config.cjs +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - preset: "jest-expo", - transformIgnorePatterns: [ - "node_modules/(?!(?:\\.pnpm/)?((jest-)?react-native|@react-native|@react-navigation|react-navigation|@expo|expo.*|@expo-google-fonts|native-base|react-native-svg))", - ], -}; diff --git a/packages/templates/expo-mobile/package.json b/packages/templates/expo-mobile/package.json index 2a929c15..86b7cbfa 100644 --- a/packages/templates/expo-mobile/package.json +++ b/packages/templates/expo-mobile/package.json @@ -8,8 +8,6 @@ "android": "expo run:android", "ios": "expo run:ios", "web": "expo start --web", - "test": "jest --watchAll=false", - "test:watch": "jest --watchAll", "lint": "oxlint .", "lint:fix": "oxlint --fix .", "format": "oxfmt --check .", @@ -44,14 +42,10 @@ }, "devDependencies": { "@babel/core": "^7.29.7", - "@react-native/jest-preset": "0.86.3", "@react-native/metro-config": "0.86.3", - "@types/jest": "^29.5.14", "@types/node": "^24.19.0", "@types/react": "~19.2.18", "babel-preset-expo": "~57.0.13", - "jest": "^29.7.0", - "jest-expo": "~57.0.5", "oxfmt": "^0.71.0", "oxlint": "^1.86.0", "typescript": "^7.0.2" diff --git a/packages/templates/expo-mobile/src/hooks/setup/swr.test.ts b/packages/templates/expo-mobile/src/hooks/setup/swr.test.ts deleted file mode 100644 index 48220fa6..00000000 --- a/packages/templates/expo-mobile/src/hooks/setup/swr.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { createNativeSWRConfig } from "./swr"; - -test("online reads do not subscribe; reconnect listeners are cleaned up", () => { - let notify: (state: { - isConnected: boolean | null; - isInternetReachable: boolean | null; - }) => void = () => {}; - const unsubscribe = jest.fn(); - const subscribe = jest.fn((listener) => { - notify = listener; - return unsubscribe; - }); - const config = createNativeSWRConfig( - { currentState: "active", addEventListener: jest.fn() }, - { addEventListener: subscribe }, - ); - const reconnect = jest.fn(); - const cleanup = config.initReconnect!(reconnect); - notify({ isConnected: false, isInternetReachable: false }); - expect(config.isOnline!()).toBe(false); - expect(config.isOnline!()).toBe(false); - expect(subscribe).toHaveBeenCalledTimes(1); - notify({ isConnected: true, isInternetReachable: true }); - expect(config.isOnline!()).toBe(true); - expect(reconnect).toHaveBeenCalledTimes(1); - cleanup!(); - expect(unsubscribe).toHaveBeenCalledTimes(1); -}); - -test("focus revalidates only when returning to foreground and releases the listener", () => { - let notify: (state: string) => void = () => {}; - const remove = jest.fn(); - const state = { - currentState: "background", - addEventListener: jest.fn((_event, listener) => { - notify = listener; - return { remove }; - }), - }; - const config = createNativeSWRConfig(state, { addEventListener: jest.fn() }); - const focus = jest.fn(); - const cleanup = config.initFocus!(focus); - notify("active"); - notify("active"); - expect(focus).toHaveBeenCalledTimes(1); - cleanup!(); - expect(remove).toHaveBeenCalledTimes(1); -}); diff --git a/packages/templates/expo-mobile/tsconfig.json b/packages/templates/expo-mobile/tsconfig.json index f876864b..fd1b8c2f 100644 --- a/packages/templates/expo-mobile/tsconfig.json +++ b/packages/templates/expo-mobile/tsconfig.json @@ -3,10 +3,19 @@ "compilerOptions": { "strict": true, "jsx": "react-native", - "types": ["node", "jest"], + "types": [ + "node" + ], "paths": { - "@/*": ["./src/*"] + "@/*": [ + "./src/*" + ] } }, - "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"] + "include": [ + "**/*.ts", + "**/*.tsx", + ".expo/types/**/*.ts", + "expo-env.d.ts" + ] } diff --git a/packages/templates/fumadocs-docs/.gitignore b/packages/templates/fumadocs-docs/.gitignore index eccd9a34..393d9417 100644 --- a/packages/templates/fumadocs-docs/.gitignore +++ b/packages/templates/fumadocs-docs/.gitignore @@ -11,7 +11,6 @@ !.yarn/versions # testing -/coverage # next.js /.next/ @@ -20,11 +19,6 @@ # production /build -# CI/CD temporary directories -deployment-package/ -/artifacts/ -*.artifact/ - # misc .DS_Store *.pem diff --git a/packages/templates/go-api/.gitignore b/packages/templates/go-api/.gitignore index 8081cbf8..d88dc11b 100644 --- a/packages/templates/go-api/.gitignore +++ b/packages/templates/go-api/.gitignore @@ -2,7 +2,6 @@ bin/ # test output -coverage.out # environment .env diff --git a/packages/templates/go-api/README.md b/packages/templates/go-api/README.md index 76ce6a50..b2019125 100644 --- a/packages/templates/go-api/README.md +++ b/packages/templates/go-api/README.md @@ -4,14 +4,14 @@ Gin · Gorm · Viper · Zap · OpenAPI · Taskfile. ## 中文 -- `task run`:启动服务(默认端口 3000)。`task build`、`task check`、`task test`:构建、检查、测试。 +- `task run`:启动服务(默认端口 3000)。`task build`、`task check`:构建与静态检查。 - 提供 `GET /`、`GET /health`、`/api/docs`,没有登录、用户 CRUD 或默认数据库。 - 配置支持 `configs/config.yaml` 和环境变量,参见 `configs/config.example.yaml`。One CLI 注入的环境变量由 Viper 读取。 - Gorm 已保留。选择驱动后,显式调用 `database.Open(dialector)` 并将连接传给自己的业务代码;迁移单独执行。初始 HTTP 服务不创建连接。 ## English -- `task run`: start on port 3000 by default. `task build`, `task check`, `task test`: build, check, test. +- `task run`: start on port 3000 by default. `task build`, `task check`: build and static checks. - Exposes `GET /`, `GET /health`, and `/api/docs`, with no login, user CRUD, or default database. - Configuration reads `configs/config.yaml` and environment variables; see `configs/config.example.yaml`. Viper reads variables injected by One CLI. - Gorm is retained. Choose a driver, explicitly call `database.Open(dialector)`, and pass the connection to your business code. Run migrations separately. The initial HTTP service does not open a connection. diff --git a/packages/templates/go-api/Taskfile.yml b/packages/templates/go-api/Taskfile.yml index 914d0bc8..ca133ce8 100644 --- a/packages/templates/go-api/Taskfile.yml +++ b/packages/templates/go-api/Taskfile.yml @@ -25,10 +25,6 @@ tasks: cmds: - go vet ./... - test: - cmds: - - go test ./... {{.CLI_ARGS}} - tidy: cmds: - go mod tidy diff --git a/packages/templates/go-api/internal/http/router_test.go b/packages/templates/go-api/internal/http/router_test.go deleted file mode 100644 index 02b85402..00000000 --- a/packages/templates/go-api/internal/http/router_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package http - -import ( - "encoding/json" - "github.com/example/one-template-go-api/internal/config" - "go.uber.org/zap" - "net/http" - "net/http/httptest" - "testing" -) - -func TestStarterRoutes(t *testing.T) { - router := NewRouter(config.Config{AppName: "starter", AppEnv: "production", AllowedOrigins: []string{"http://localhost:5173"}}, zap.NewNop()) - for _, tt := range []struct { - method string - path string - status int - }{{http.MethodGet, "/", 200}, {http.MethodGet, "/health", 200}, {http.MethodPost, "/auth/login", 404}, {http.MethodGet, "/api/users", 404}} { - t.Run(tt.path, func(t *testing.T) { - recorder := httptest.NewRecorder() - router.ServeHTTP(recorder, httptest.NewRequest(tt.method, tt.path, nil)) - if recorder.Code != tt.status { - t.Fatalf("status = %d, want %d", recorder.Code, tt.status) - } - if tt.path == "/health" { - var body map[string]any - if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil { - t.Fatal(err) - } - if body["status"] != "ok" { - t.Fatalf("health = %v", body) - } - } - }) - } - for _, tt := range []struct{ origin, allowed string }{{"http://localhost:5173", "http://localhost:5173"}, {"https://localhost.example.com", ""}} { - t.Run(tt.origin, func(t *testing.T) { - request := httptest.NewRequest(http.MethodOptions, "/health", nil) - request.Header.Set("Origin", tt.origin) - recorder := httptest.NewRecorder() - router.ServeHTTP(recorder, request) - if got := recorder.Header().Get("Access-Control-Allow-Origin"); got != tt.allowed { - t.Fatalf("origin = %q, want %q", got, tt.allowed) - } - }) - } -} diff --git a/packages/templates/go-lib/.gitignore b/packages/templates/go-lib/.gitignore index 238f5630..3d57546b 100644 --- a/packages/templates/go-lib/.gitignore +++ b/packages/templates/go-lib/.gitignore @@ -3,8 +3,6 @@ bin/ dist/ # test output -coverage.out -coverage.html # editor / OS .DS_Store diff --git a/packages/templates/go-lib/README.md b/packages/templates/go-lib/README.md index ca4859ad..946e464c 100644 --- a/packages/templates/go-lib/README.md +++ b/packages/templates/go-lib/README.md @@ -11,7 +11,6 @@ Requires Go 1.27 or newer. Use the latest patch release for your Go version. go mod tidy task build task check -task test ``` ## Layout @@ -19,7 +18,7 @@ task test ```text pkg/greeter/ # public API — what consumers import go.mod # module declaration -Taskfile.yml # go-task tasks (build / fmt / vet / test / tidy / check) +Taskfile.yml # go-task tasks (build / fmt / vet / tidy / check) LICENSE # MIT ``` diff --git a/packages/templates/go-lib/Taskfile.yml b/packages/templates/go-lib/Taskfile.yml index 4c23c0dc..9ff8d1bc 100644 --- a/packages/templates/go-lib/Taskfile.yml +++ b/packages/templates/go-lib/Taskfile.yml @@ -17,15 +17,6 @@ tasks: cmds: - go vet ./... - test: - cmds: - - go test -race ./... {{.CLI_ARGS}} - - test:cover: - cmds: - - go test -race -coverprofile=coverage.out ./... - - go tool cover -func=coverage.out - tidy: cmds: - go mod tidy diff --git a/packages/templates/go-lib/pkg/greeter/greeter_test.go b/packages/templates/go-lib/pkg/greeter/greeter_test.go deleted file mode 100644 index 64c788c9..00000000 --- a/packages/templates/go-lib/pkg/greeter/greeter_test.go +++ /dev/null @@ -1,21 +0,0 @@ -package greeter - -import "testing" - -func TestGreet(t *testing.T) { - tests := []struct { - name string - in string - want string - }{ - {name: "named", in: "Ada", want: "Hello, Ada!"}, - {name: "empty falls back to world", in: "", want: "Hello, world!"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := Greet(tt.in); got != tt.want { - t.Errorf("Greet(%q) = %q, want %q", tt.in, got, tt.want) - } - }) - } -} diff --git a/packages/templates/nestjs-api/.gitignore b/packages/templates/nestjs-api/.gitignore index 888c395f..8d4bb015 100644 --- a/packages/templates/nestjs-api/.gitignore +++ b/packages/templates/nestjs-api/.gitignore @@ -16,7 +16,6 @@ lerna-debug.log* .DS_Store # Tests -/coverage /.nyc_output # IDEs and editors diff --git a/packages/templates/nestjs-api/README.md b/packages/templates/nestjs-api/README.md index f7c1c780..d9709837 100644 --- a/packages/templates/nestjs-api/README.md +++ b/packages/templates/nestjs-api/README.md @@ -7,7 +7,7 @@ NestJS · TypeScript · Drizzle ORM/Kit · class-validator · Swagger · Pino · 初始项目只提供 `GET /`、`GET /health` 和 `/api/docs`,无需数据库或认证配置即可运行。 - `pnpm start:dev`:开发;`pnpm build` / `pnpm start:prod`:构建与启动。 -- `pnpm check`、`pnpm test`、`pnpm test:e2e`:检查与测试。 +- `pnpm check`:类型、代码与格式检查。 - 使用 One CLI 注入 `PORT`、`ALLOWED_ORIGINS`,参见 `.env.example`。 - 已保留 Drizzle。需要数据持久化时,先选择并安装驱动,再编写 schema、连接和 Drizzle Kit 配置。迁移由独立任务显式执行。 - 业务功能与认证通过手动安装业务 skills 或项目代码添加。 @@ -17,7 +17,7 @@ NestJS · TypeScript · Drizzle ORM/Kit · class-validator · Swagger · Pino · The starter exposes only `GET /`, `GET /health`, and `/api/docs`. It starts without database or authentication settings. - `pnpm start:dev`: development; `pnpm build` / `pnpm start:prod`: build and run. -- `pnpm check`, `pnpm test`, `pnpm test:e2e`: validation. +- `pnpm check`: type, code, and formatting checks. - Inject `PORT` and `ALLOWED_ORIGINS` with One CLI; see `.env.example`. - Drizzle is retained. Choose and install a driver before adding your schema, connection, and Drizzle Kit configuration. Run migrations as explicit, separate tasks. - Add business features and authentication through manually installed business skills or application code. diff --git a/packages/templates/nestjs-api/package.json b/packages/templates/nestjs-api/package.json index 5b61327a..5f2a70e8 100644 --- a/packages/templates/nestjs-api/package.json +++ b/packages/templates/nestjs-api/package.json @@ -12,11 +12,6 @@ "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", "start:prod": "node dist/main", - "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", - "test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch", - "test:cov": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage", - "test:debug": "node --experimental-vm-modules --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/jest/bin/jest.js --runInBand", - "test:e2e": "node --experimental-vm-modules node_modules/jest/bin/jest.js --config ./test/jest-e2e.json", "lint": "oxlint .", "format": "oxfmt --check .", "lint:fix": "oxlint --fix .", @@ -41,43 +36,17 @@ "devDependencies": { "@nestjs/cli": "^12.0.8", "@nestjs/schematics": "^12.0.6", - "@nestjs/testing": "^12.1.1", "@types/express": "^5.0.6", - "@types/jest": "^30.0.0", "@types/node": "^24.19.0", - "@types/supertest": "^7.2.1", "drizzle-kit": "^0.31.11", - "jest": "^30.5.2", "oxfmt": "^0.71.0", "oxlint": "^1.86.0", "source-map-support": "^0.5.21", - "supertest": "^7.3.0", - "ts-jest": "^29.4.14", "ts-loader": "^9.6.2", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", "typescript": "^6.0.3" }, - "jest": { - "collectCoverageFrom": [ - "**/*.(t|j)s" - ], - "coverageDirectory": "../coverage", - "moduleFileExtensions": [ - "js", - "json", - "ts" - ], - "moduleNameMapper": { - "^@/(.*)$": "/$1" - }, - "rootDir": "src", - "testEnvironment": "node", - "testRegex": ".*\\.spec\\.ts$", - "transform": { - "^.+\\.(t|j)s$": "ts-jest" - } - }, "engines": { "node": "^24.15.0 || >=26.0.0" }, diff --git a/packages/templates/nestjs-api/src/app.controller.spec.ts b/packages/templates/nestjs-api/src/app.controller.spec.ts deleted file mode 100644 index c28b0b98..00000000 --- a/packages/templates/nestjs-api/src/app.controller.spec.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { AppController } from "./app.controller"; - -describe("AppController", () => { - it("reports a live process", () => { - const result = new AppController().health(); - expect(result.status).toBe("ok"); - expect(result.uptime).toBeGreaterThanOrEqual(0); - }); -}); diff --git a/packages/templates/nestjs-api/test/app.e2e-spec.ts b/packages/templates/nestjs-api/test/app.e2e-spec.ts deleted file mode 100644 index 3a3f9495..00000000 --- a/packages/templates/nestjs-api/test/app.e2e-spec.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { INestApplication } from "@nestjs/common"; -import { Test } from "@nestjs/testing"; -import request from "supertest"; -import { AppModule } from "../src/app.module"; - -describe("Starter API", () => { - let app: INestApplication; - beforeAll(async () => { - const module = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); - app = module.createNestApplication(); - await app.init(); - }); - afterAll(async () => { - await app.close(); - }); - it("starts without database or authentication configuration", async () => { - await request(app.getHttpServer()) - .get("/") - .expect(200) - .expect({ name: "nestjs-api", version: "0.1.0" }); - const response = await request(app.getHttpServer()) - .get("/health") - .expect(200); - expect(response.body.status).toBe("ok"); - expect(response.body).not.toHaveProperty("database"); - }); - it("does not include login", async () => { - await request(app.getHttpServer()) - .post("/auth/login") - .send({ code: "demo" }) - .expect(404); - }); - it.each(["/users", "/api/v1/users"])( - "does not include business routes: %s", - async (path) => { - await request(app.getHttpServer()).get(path).expect(404); - }, - ); -}); diff --git a/packages/templates/nestjs-api/test/jest-e2e.json b/packages/templates/nestjs-api/test/jest-e2e.json deleted file mode 100644 index 795e0bd8..00000000 --- a/packages/templates/nestjs-api/test/jest-e2e.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "moduleFileExtensions": ["js", "json", "ts"], - "rootDir": ".", - "moduleNameMapper": { - "^@/(.*)$": "/../src/$1" - }, - "testEnvironment": "node", - "testRegex": ".e2e-spec.ts$", - "transform": { - "^.+\\.(t|j)s$": "ts-jest" - } -} diff --git a/packages/templates/nestjs-api/tsconfig.build.json b/packages/templates/nestjs-api/tsconfig.build.json index bcea9b8e..76d884f2 100644 --- a/packages/templates/nestjs-api/tsconfig.build.json +++ b/packages/templates/nestjs-api/tsconfig.build.json @@ -3,7 +3,12 @@ "compilerOptions": { "rootDir": "./src", "tsBuildInfoFile": "./dist/tsconfig.build.tsbuildinfo", - "types": ["node"] + "types": [ + "node" + ] }, - "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] + "exclude": [ + "node_modules", + "dist" + ] } diff --git a/packages/templates/nestjs-api/tsconfig.json b/packages/templates/nestjs-api/tsconfig.json index a8226ae6..78662472 100644 --- a/packages/templates/nestjs-api/tsconfig.json +++ b/packages/templates/nestjs-api/tsconfig.json @@ -14,7 +14,9 @@ "rootDir": ".", "outDir": "./dist", "paths": { - "@/*": ["./src/*"] + "@/*": [ + "./src/*" + ] }, "incremental": true, "skipLibCheck": true, @@ -23,7 +25,12 @@ "strictBindCallApply": false, "forceConsistentCasingInFileNames": false, "noFallthroughCasesInSwitch": false, - "typeRoots": ["./node_modules/@types", "./src/types"], - "types": ["node", "jest"] + "typeRoots": [ + "./node_modules/@types", + "./src/types" + ], + "types": [ + "node" + ] } } diff --git a/packages/templates/nextjs-app/.gitignore b/packages/templates/nextjs-app/.gitignore index 7c064b01..6b29a717 100644 --- a/packages/templates/nextjs-app/.gitignore +++ b/packages/templates/nextjs-app/.gitignore @@ -11,7 +11,6 @@ !.yarn/versions # testing -/coverage # next.js /.next/ @@ -20,11 +19,6 @@ # production /build -# CI/CD temporary directories -deployment-package/ -/artifacts/ -*.artifact/ - # misc .DS_Store *.pem diff --git a/packages/templates/nextjs-site/.gitignore b/packages/templates/nextjs-site/.gitignore index eccd9a34..393d9417 100644 --- a/packages/templates/nextjs-site/.gitignore +++ b/packages/templates/nextjs-site/.gitignore @@ -11,7 +11,6 @@ !.yarn/versions # testing -/coverage # next.js /.next/ @@ -20,11 +19,6 @@ # production /build -# CI/CD temporary directories -deployment-package/ -/artifacts/ -*.artifact/ - # misc .DS_Store *.pem diff --git a/packages/templates/registry.json b/packages/templates/registry.json index 9479ca73..001d0009 100644 --- a/packages/templates/registry.json +++ b/packages/templates/registry.json @@ -116,14 +116,13 @@ { "id": "ts-library", "name": "TypeScript 通用库", - "description": "通用库模板,适合发包场景,内置 tsdown + vitest + oxlint/oxfmt", + "description": "通用库模板,适合发包场景,内置 tsdown + oxlint/oxfmt", "category": "library", "tags": [ "library", "typescript", "npm", - "tsdown", - "vitest" + "tsdown" ], "toolchain": "node", "repo": "local:ts-library", diff --git a/packages/templates/ts-library/.gitignore b/packages/templates/ts-library/.gitignore index f602c43a..b2a2d655 100644 --- a/packages/templates/ts-library/.gitignore +++ b/packages/templates/ts-library/.gitignore @@ -1,7 +1,6 @@ node_modules dist -coverage .DS_Store *.log diff --git a/packages/templates/ts-library/README.md b/packages/templates/ts-library/README.md index 8d1dabe3..bd7ab1bf 100644 --- a/packages/templates/ts-library/README.md +++ b/packages/templates/ts-library/README.md @@ -3,7 +3,6 @@ 面向可发布 npm 包的通用库模板,内置: - 构建:`tsdown` -- 测试:`vitest` - 代码质量:`oxlint` + `oxfmt` ## 开发 @@ -19,13 +18,6 @@ pnpm dev pnpm build ``` -## 测试 - -```bash -pnpm test -pnpm test:coverage -``` - ## 发包 发包前请先修改 `package.json` 中的 `name`、`version` 等信息。 diff --git a/packages/templates/ts-library/package.json b/packages/templates/ts-library/package.json index ed25eb2f..4867012c 100644 --- a/packages/templates/ts-library/package.json +++ b/packages/templates/ts-library/package.json @@ -24,26 +24,21 @@ "scripts": { "dev": "tsdown --watch", "build": "tsdown", - "test": "vitest run", - "test:watch": "vitest", - "test:coverage": "vitest run --coverage", "lint": "oxlint .", "lint:fix": "oxlint --fix .", "format": "oxfmt --check .", "format:fix": "oxfmt --write .", "check": "pnpm run lint && pnpm run format", "check:fix": "pnpm run lint:fix && pnpm run format:fix", - "prepublishOnly": "pnpm run check && pnpm run test && pnpm run build", + "prepublishOnly": "pnpm run check && pnpm run build", "publish:public": "pnpm publish --access public" }, "devDependencies": { "@types/node": "^24.19.0", - "@vitest/coverage-v8": "^5.0.2", "oxfmt": "^0.71.0", "oxlint": "^1.86.0", "tsdown": "^0.23.0", - "typescript": "^7.0.2", - "vitest": "^5.0.2" + "typescript": "^7.0.2" }, "engines": { "node": "^24.15.0 || >=26.0.0" diff --git a/packages/templates/ts-library/src/index.test.ts b/packages/templates/ts-library/src/index.test.ts deleted file mode 100644 index 9f3dd3d1..00000000 --- a/packages/templates/ts-library/src/index.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { clamp } from "./index"; - -describe("clamp", () => { - it("limits value into given boundaries", () => { - expect(clamp(10, 0, 5)).toBe(5); - expect(clamp(-3, 0, 5)).toBe(0); - expect(clamp(3, 0, 5)).toBe(3); - }); - - it("throws when min is greater than max", () => { - expect(() => clamp(1, 5, 0)).toThrowError("min must be less than or equal to max"); - }); -}); diff --git a/packages/templates/ts-library/tsconfig.json b/packages/templates/ts-library/tsconfig.json index 6cae078b..c5cf7113 100644 --- a/packages/templates/ts-library/tsconfig.json +++ b/packages/templates/ts-library/tsconfig.json @@ -1,7 +1,9 @@ { "compilerOptions": { "target": "ES2022", - "lib": ["ES2022"], + "lib": [ + "ES2022" + ], "module": "ESNext", "moduleResolution": "Bundler", "strict": true, @@ -10,7 +12,12 @@ "sourceMap": true, "esModuleInterop": true, "skipLibCheck": true, - "types": ["node", "vitest/globals"] + "types": [ + "node" + ] }, - "include": ["src", "tsdown.config.ts", "vitest.config.ts"] + "include": [ + "src", + "tsdown.config.ts" + ] } diff --git a/packages/templates/ts-library/vitest.config.ts b/packages/templates/ts-library/vitest.config.ts deleted file mode 100644 index a085aec1..00000000 --- a/packages/templates/ts-library/vitest.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - environment: "node", - include: ["src/**/*.test.ts"], - coverage: { - provider: "v8", - reporter: ["text", "html"], - }, - }, -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8dc23016..2f3a6241 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -163,12 +163,6 @@ importers: specifier: ^5.0.15 version: 5.0.15(@types/react@19.3.0)(react@19.3.0)(use-sync-external-store@1.7.0(react@19.3.0)) devDependencies: - '@testing-library/react': - specifier: ^16.3.3 - version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - '@testing-library/user-event': - specifier: ^14.6.7 - version: 14.6.7(@testing-library/dom@10.4.1) '@types/node': specifier: ^26.6.3 version: 26.6.3 @@ -181,12 +175,6 @@ importers: '@vitejs/plugin-react': specifier: ^6.1.1 version: 6.1.1(vite@8.3.1(@types/node@26.6.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) - jsdom: - specifier: ^30.1.1 - version: 30.1.1(@noble/hashes@1.8.0) - msw: - specifier: ^2.15.0 - version: 2.15.0(@types/node@26.6.3)(typescript@7.0.2) oxfmt: specifier: ^0.70.0 version: 0.70.0 @@ -199,9 +187,6 @@ importers: vite: specifier: ^8.3.1 version: 8.3.1(@types/node@26.6.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1) - vitest: - specifier: ^5.0.2 - version: 5.0.2(@types/node@26.6.3)(jsdom@30.1.1(@noble/hashes@1.8.0))(msw@2.15.0(@types/node@26.6.3)(typescript@7.0.2))(vite@8.3.1(@types/node@26.6.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) apps/docs: dependencies: @@ -285,14 +270,6 @@ packages: resolution: {integrity: sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==} engines: {node: '>=10'} - '@asamuzakjp/css-color@7.1.2': - resolution: {integrity: sha512-99DHAnXDB5z6EEK+9GMpVI7Mw4oxj97dY5bpOzMnjADQWxI8rN6TvTduuFLUhUMlS7/CfVZ06tcZsus6cltnNw==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - - '@asamuzakjp/dom-selector@9.2.2': - resolution: {integrity: sha512-lSWTBMjAcmu2xn5yEDU7jh6QDV+C8GEKtdJ4pIQhXh26RkKQ7S3FuQlt+zZkUbTdJ4d3XyV1kPI/G0zWXxqaqw==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -453,46 +430,6 @@ packages: '@types/react': optional: true - '@bramus/specificity@2.4.2': - resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} - hasBin: true - - '@csstools/color-helpers@6.1.2': - resolution: {integrity: sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==} - engines: {node: '>=20.19.0'} - - '@csstools/css-calc@3.4.1': - resolution: {integrity: sha512-EtC7SoN1j6J4E4DCwg5QgbO5TGxgxIA1RXqe+W+qUM+BUcezx9wT+/tiQ/WO2yCX4i5X+Cuf9ciJ22aP4UEwWw==} - engines: {node: '>=20.19.0'} - peerDependencies: - '@csstools/css-parser-algorithms': ^4.0.1 - '@csstools/css-tokenizer': ^4.0.2 - - '@csstools/css-color-parser@4.2.4': - resolution: {integrity: sha512-DyefytAZ735mX4Dq/WcDAXFtXhaEFvme0ZS9tVEBAc2whxUthXr0R0L2rmEPm59SrMBkGrFzQviBXMs/UtnABQ==} - engines: {node: '>=20.19.0'} - peerDependencies: - '@csstools/css-parser-algorithms': ^4.0.1 - '@csstools/css-tokenizer': ^4.0.2 - - '@csstools/css-parser-algorithms@4.0.1': - resolution: {integrity: sha512-ShL8BqPfbKJrJiKFH0xBbN0i7Nrh9HXYRuF+pzyj93R/BL2YAsUxJeqANErzqM+0JGl7vjHp+3OIgd/DL69YIA==} - engines: {node: '>=20.19.0'} - peerDependencies: - '@csstools/css-tokenizer': ^4.0.2 - - '@csstools/css-syntax-patches-for-csstree@1.1.14': - resolution: {integrity: sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==} - peerDependencies: - css-tree: ^3.2.1 - peerDependenciesMeta: - css-tree: - optional: true - - '@csstools/css-tokenizer@4.0.2': - resolution: {integrity: sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==} - engines: {node: '>=20.19.0'} - '@dimforge/rapier3d-compat@0.12.0': resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} @@ -662,15 +599,6 @@ packages: cpu: [x64] os: [win32] - '@exodus/bytes@1.16.0': - resolution: {integrity: sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - peerDependencies: - '@noble/hashes': ^1.8.0 || ^2.0.0 - peerDependenciesMeta: - '@noble/hashes': - optional: true - '@floating-ui/core@1.8.0': resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} @@ -859,41 +787,6 @@ packages: cpu: [x64] os: [win32] - '@inquirer/ansi@2.0.8': - resolution: {integrity: sha512-WpQM+Ti6Z40EFwwt+uL2p4UabT+W179zHp6HhLVOzfbwnVn05IPO/eXIZXGNqcT1jbQ15SujNLzQ39k4QPPxBQ==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - - '@inquirer/confirm@6.3.2': - resolution: {integrity: sha512-Xvr/0HggjddPtGppuqVmxhTw+Hr8PvsZ/k0HmOEaAqQEt80OITNkFWnsdNmyT0/eM4Ab+iJLx2R8rctlEyfSVg==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/core@12.0.3': - resolution: {integrity: sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - - '@inquirer/figures@2.0.9': - resolution: {integrity: sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - - '@inquirer/type@4.1.1': - resolution: {integrity: sha512-yJoHYrMnxIsJZCY+0Vb66Dy3he3kL3e2wOBKhoSwWWAzZAY82emlxwgprCtp6yRixvNRNq9ztfRWQYPNr3Go7A==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - peerDependencies: - '@types/node': '>=18' - peerDependenciesMeta: - '@types/node': - optional: true - '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -923,10 +816,6 @@ packages: '@cfworker/json-schema': optional: true - '@mswjs/interceptors@0.41.9': - resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==} - engines: {node: '>=18'} - '@next/env@16.3.6': resolution: {integrity: sha512-x9Vblze1EbtltQYnNH38xCPWU3TVfBd1eXqA3+w9+BTpedkkdNpAaltXlGQ/nsc1+E0mVTNrtcbX3GoO09zeLQ==} @@ -978,10 +867,6 @@ packages: cpu: [x64] os: [win32] - '@noble/hashes@1.8.0': - resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} - engines: {node: ^14.21.3 || >=16} - '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -994,18 +879,6 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@open-draft/deferred-promise@2.2.0': - resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} - - '@open-draft/deferred-promise@3.0.0': - resolution: {integrity: sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==} - - '@open-draft/logger@0.3.0': - resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} - - '@open-draft/until@2.1.0': - resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} - '@oxc-project/types@0.151.0': resolution: {integrity: sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==} @@ -2157,49 +2030,15 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 - '@testing-library/dom@10.4.1': - resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} - engines: {node: '>=18'} - - '@testing-library/react@16.3.3': - resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} - engines: {node: '>=18'} - peerDependencies: - '@testing-library/dom': ^10.0.0 - '@types/react': ^18.0.0 || ^19.0.0 - '@types/react-dom': ^18.0.0 || ^19.0.0 - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@testing-library/user-event@14.6.7': - resolution: {integrity: sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==} - engines: {node: '>=12', npm: '>=6'} - peerDependencies: - '@testing-library/dom': '>=7.21.4' - '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} '@tweenjs/tween.js@23.1.3': resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} - '@types/aria-query@5.0.4': - resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} - - '@types/chai@5.2.3': - resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} - '@types/deep-eql@4.0.2': - resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -2229,15 +2068,9 @@ packages: '@types/react@19.3.0': resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} - '@types/set-cookie-parser@2.4.10': - resolution: {integrity: sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==} - '@types/stats.js@0.17.4': resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} - '@types/statuses@2.0.6': - resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} - '@types/three@0.186.0': resolution: {integrity: sha512-mxYSBpDC+D0pLfSP6sW4WZTcT+nrtmZcimMqnVmy36Hte3XpeYSrvgg4TRdaM1GemGog1AWzI5qL2VoIfMXbJQ==} @@ -2392,20 +2225,6 @@ packages: oxc-transform-react: optional: true - '@vitest/mocker@5.0.2': - resolution: {integrity: sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==} - peerDependencies: - msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - msw: - optional: true - vite: - optional: true - - '@vitest/spy@5.0.2': - resolution: {integrity: sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==} - '@yuku-analyzer/binding-android-arm64@0.10.2': resolution: {integrity: sha512-xt4Vy3RTBw1NfszBlf4A0Rr6ZjA/th2Pj9XlWFLQHW9A1qOCtxNCoIWSzn/INtnZLMB4jO2gWdEtSaHUtK73wA==} cpu: [arm64] @@ -2518,14 +2337,6 @@ packages: resolution: {integrity: sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==} engines: {node: '>=12'} - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - ansi-styles@5.2.0: - resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} - engines: {node: '>=10'} - argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -2533,13 +2344,6 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} - aria-query@5.3.0: - resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} - - assertion-error@2.0.1: - resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} - engines: {node: '>=12'} - ast-types@0.16.3: resolution: {integrity: sha512-FvWoWYfSCM6kRxCSH+MGLHIKKGRL6A6AW7Zek2O32REPQRdg131428uRTKMBYAeRd3XXAaHDS60Wpri7CdKDrA==} engines: {node: '>=4'} @@ -2570,9 +2374,6 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - bidi-js@1.1.0: - resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} - body-parser@2.3.0: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} @@ -2616,10 +2417,6 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} - chai@6.2.2: - resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} - engines: {node: '>=18'} - chalk@5.6.2: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} @@ -2651,17 +2448,9 @@ packages: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} - cli-width@4.1.0: - resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} - engines: {node: '>= 12'} - client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} - cliui@8.0.1: - resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} - engines: {node: '>=12'} - clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -2682,13 +2471,6 @@ packages: collapse-white-space@2.1.0: resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -2755,10 +2537,6 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - css-tree@3.2.1: - resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} - engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} - cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -2767,10 +2545,6 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - data-urls@7.0.0: - resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - debounce-fn@4.0.0: resolution: {integrity: sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==} engines: {node: '>=10'} @@ -2784,9 +2558,6 @@ packages: supports-color: optional: true - decimal.js@10.6.0: - resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} - decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -2844,9 +2615,6 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} - dom-accessibility-api@0.5.16: - resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} - dot-prop@6.0.1: resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==} engines: {node: '>=10'} @@ -2868,9 +2636,6 @@ packages: emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -2887,10 +2652,6 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} - entities@8.1.0: - resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} - engines: {node: '>=20.19.0'} - env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -2906,9 +2667,6 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-module-lexer@2.3.2: - resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} - es-object-atoms@1.1.2: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} @@ -2988,10 +2746,6 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} - expect-type@1.4.0: - resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} - engines: {node: '>=12.0.0'} - express-rate-limit@8.7.0: resolution: {integrity: sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==} engines: {node: '>= 16'} @@ -3012,18 +2766,9 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} - fast-string-truncated-width@3.0.3: - resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} - - fast-string-width@3.0.2: - resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - fast-uri@3.1.8: resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} - fast-wrap-ansi@0.2.2: - resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} - fastq@1.20.3: resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} @@ -3225,10 +2970,6 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} - get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - get-east-asian-width@1.7.0: resolution: {integrity: sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==} engines: {node: '>=18'} @@ -3271,10 +3012,6 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - graphql@16.14.2: - resolution: {integrity: sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==} - engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} - has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -3314,17 +3051,10 @@ packages: hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} - headers-polyfill@5.0.1: - resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==} - hono@4.13.9: resolution: {integrity: sha512-7dMkQmZoC4E6F7AtaQSPhlWAdnBti+j7rreMZl8QB4jFiEhP9TWbGWUMi8WYzBCgmgulxuvLQupKqo+Co6Omyg==} engines: {node: '>=16.9.0'} - html-encoding-sniffer@7.0.0: - resolution: {integrity: sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==} - engines: {node: ^22.13.0 || >=24.0.0} - html-parse-stringify@4.0.1: resolution: {integrity: sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==} @@ -3407,10 +3137,6 @@ packages: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -3431,9 +3157,6 @@ packages: resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} engines: {node: '>=12'} - is-node-process@1.2.0: - resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} - is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -3450,9 +3173,6 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} - is-potential-custom-element-name@1.0.1: - resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} - is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -3505,15 +3225,6 @@ packages: resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true - jsdom@30.1.1: - resolution: {integrity: sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - peerDependencies: - canvas: ^3.2.3 - peerDependenciesMeta: - canvas: - optional: true - jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -3701,10 +3412,6 @@ packages: longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} - lru-cache@11.5.3: - resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} - engines: {node: 20 || >=22} - lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -3713,10 +3420,6 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - lz-string@1.5.0: - resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} - hasBin: true - magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -3782,9 +3485,6 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} - mdn-data@2.27.1: - resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} - media-typer@1.1.1: resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} engines: {node: '>= 0.8'} @@ -3970,20 +3670,6 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - msw@2.15.0: - resolution: {integrity: sha512-2wQAmKkQKxRuXvYJxVhPGG0wZNBQyD06oJvxqw90XqLvptdqxdlHrFUfEteKkpaNORX3Xzc+HtEl/q0nfmN2wQ==} - engines: {node: '>=18'} - hasBin: true - peerDependencies: - typescript: '>= 4.8.x' - peerDependenciesMeta: - typescript: - optional: true - - mute-stream@3.0.0: - resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} - engines: {node: ^20.17.0 || >=22.9.0} - nanoid@3.3.19: resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -4055,10 +3741,6 @@ packages: resolution: {integrity: sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==} engines: {node: '>= 10'} - obug@2.2.1: - resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} - engines: {node: '>=12.20.0'} - on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -4092,9 +3774,6 @@ packages: resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} engines: {node: '>=18'} - outvariant@1.4.3: - resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} - oxfmt@0.70.0: resolution: {integrity: sha512-IsHxZ4y0wQLLMhnrJblBJgZsLDzfULrJnAw5j/QqsTlMa/m3AqsbToi+W71uhBGaqlqq/PbbjvHc09TJwdv3Tw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4151,9 +3830,6 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} - parse5@8.0.1: - resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} - parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -4173,9 +3849,6 @@ packages: resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} engines: {node: '>=12'} - path-to-regexp@6.3.0: - resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} - path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} @@ -4218,10 +3891,6 @@ packages: resolution: {integrity: sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==} engines: {node: '>=20'} - pretty-format@27.5.1: - resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} - engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} - pretty-ms@9.3.1: resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==} engines: {node: '>=18'} @@ -4241,10 +3910,6 @@ packages: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} - qs@6.16.0: resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} engines: {node: '>=0.6'} @@ -4294,9 +3959,6 @@ packages: typescript: optional: true - react-is@17.0.2: - resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} - react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -4403,10 +4065,6 @@ packages: remark@15.0.1: resolution: {integrity: sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==} - require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} - require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -4422,9 +4080,6 @@ packages: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} - rettime@0.11.12: - resolution: {integrity: sha512-5ExtR1ppiDBQkVoPw5NAqdbfH4Iq2VOmQKHMtUoaFxA9bo2krzFYBtyq6K40xxbYp+2w/YOr6D+1ToF6tTN53A==} - reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -4448,10 +4103,6 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - saxes@6.0.0: - resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} - engines: {node: '>=v12.22.7'} - scheduler@0.28.0: resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} @@ -4478,9 +4129,6 @@ packages: set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} - set-cookie-parser@3.1.2: - resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==} - setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -4573,20 +4221,10 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} - std-env@4.2.0: - resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} - stdin-discarder@0.2.2: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} engines: {node: '>=18'} - strict-event-emitter@0.5.1: - resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} - - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - string-width@7.2.0: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} @@ -4648,10 +4286,6 @@ packages: os: [darwin, linux, win32, freebsd, openbsd, netbsd, sunos, android] hasBin: true - tagged-tag@1.0.0: - resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} - engines: {node: '>=20'} - tailwind-merge@3.7.0: resolution: {integrity: sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==} @@ -4668,10 +4302,6 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} - tinybench@6.2.0: - resolution: {integrity: sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==} - engines: {node: '>=20.0.0'} - tinyexec@1.3.1: resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} engines: {node: '>=18'} @@ -4684,13 +4314,6 @@ packages: resolution: {integrity: sha512-9YodfrxS9g9IbFr/KOjE5bAeJ0p61n3bW6mqvy0jtoeKd1kTW1Cxm0oulm6KX2lyM9Gl6WIe8nEbY7LWv5ZJww==} engines: {node: ^20.0.0 || >=22.0.0} - tldts-core@7.4.16: - resolution: {integrity: sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA==} - - tldts@7.4.16: - resolution: {integrity: sha512-QwBER5KMR86IIjpIiO7H/Z3IMJPsZ1A6RKPAqzTTgOyUQUSt9FdnKcqhTaJmkY6HVrgouZHZR0ncK5QxvmnQeg==} - hasBin: true - to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -4699,14 +4322,6 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} - tough-cookie@6.0.2: - resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} - engines: {node: '>=16'} - - tr46@6.0.0: - resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} - engines: {node: '>=20'} - trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -4726,10 +4341,6 @@ packages: tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} - type-fest@5.10.0: - resolution: {integrity: sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==} - engines: {node: '>=20'} - type-is@2.1.0: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} @@ -4746,10 +4357,6 @@ packages: resolution: {integrity: sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==} engines: {node: '>=20.18.1'} - undici@8.11.2: - resolution: {integrity: sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==} - engines: {node: '>=22.19.0'} - unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} @@ -4786,9 +4393,6 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} - until-async@3.0.2: - resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} - update-browserslist-db@1.3.3: resolution: {integrity: sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==} hasBin: true @@ -4883,70 +4487,9 @@ packages: yaml: optional: true - vitest@5.0.2: - resolution: {integrity: sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==} - engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} - hasBin: true - peerDependencies: - '@edge-runtime/vm': '*' - '@opentelemetry/api': ^1.9.0 - '@types/node': ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 5.0.2 - '@vitest/browser-preview': 5.0.2 - '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 - '@vitest/coverage-istanbul': 5.0.2 - '@vitest/coverage-v8': 5.0.2 - '@vitest/ui': 5.0.2 - happy-dom: '*' - jsdom: '*' - vite: ^6.4.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - '@edge-runtime/vm': - optional: true - '@opentelemetry/api': - optional: true - '@types/node': - optional: true - '@vitest/browser-playwright': - optional: true - '@vitest/browser-preview': - optional: true - '@vitest/browser-webdriverio': - optional: true - '@vitest/coverage-istanbul': - optional: true - '@vitest/coverage-v8': - optional: true - '@vitest/ui': - optional: true - happy-dom: - optional: true - jsdom: - optional: true - - w3c-xmlserializer@6.0.0: - resolution: {integrity: sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==} - engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} - web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} - webidl-conversions@8.0.1: - resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} - engines: {node: '>=20'} - - whatwg-mimetype@5.0.0: - resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} - engines: {node: '>=20'} - - whatwg-url@16.0.1: - resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} - engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} - - whatwg-url@17.1.2: - resolution: {integrity: sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==} - engines: {node: ^22.14.0 || >=24.0.0} - which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -4957,15 +4500,6 @@ packages: engines: {node: ^16.13.0 || >=18.0.0} hasBin: true - why-is-node-running@3.2.2: - resolution: {integrity: sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==} - engines: {node: '>=20.11'} - hasBin: true - - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -4973,17 +4507,6 @@ packages: resolution: {integrity: sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA==} engines: {node: '>=20'} - xml-name-validator@5.0.0: - resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} - engines: {node: '>=18'} - - xmlchars@2.2.0: - resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} - - y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -4992,14 +4515,6 @@ packages: engines: {node: '>= 14.6'} hasBin: true - yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} - - yargs@17.7.3: - resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} - engines: {node: '>=12'} - yocto-spinner@1.2.2: resolution: {integrity: sha512-DODGl1wJjA/s5pnJFKau9lIYHT81lnhob1i3e1TjxZRxEhWRKl74nTbWE6H5KlkViQQTo/Z29YFdxzTZAMY3ng==} engines: {node: '>=18.19'} @@ -5054,21 +4569,6 @@ snapshots: '@alloc/quick-lru@5.3.0': {} - '@asamuzakjp/css-color@7.1.2': - dependencies: - '@csstools/css-calc': 3.4.1(@csstools/css-parser-algorithms@4.0.1(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) - '@csstools/css-color-parser': 4.2.4(@csstools/css-parser-algorithms@4.0.1(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) - '@csstools/css-parser-algorithms': 4.0.1(@csstools/css-tokenizer@4.0.2) - '@csstools/css-tokenizer': 4.0.2 - lru-cache: 11.5.3 - - '@asamuzakjp/dom-selector@9.2.2': - dependencies: - bidi-js: 1.1.0 - css-tree: 3.2.1 - is-potential-custom-element-name: 1.0.1 - lru-cache: 11.5.3 - '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -5280,34 +4780,6 @@ snapshots: optionalDependencies: '@types/react': 19.3.0 - '@bramus/specificity@2.4.2': - dependencies: - css-tree: 3.2.1 - - '@csstools/color-helpers@6.1.2': {} - - '@csstools/css-calc@3.4.1(@csstools/css-parser-algorithms@4.0.1(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': - dependencies: - '@csstools/css-parser-algorithms': 4.0.1(@csstools/css-tokenizer@4.0.2) - '@csstools/css-tokenizer': 4.0.2 - - '@csstools/css-color-parser@4.2.4(@csstools/css-parser-algorithms@4.0.1(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': - dependencies: - '@csstools/color-helpers': 6.1.2 - '@csstools/css-calc': 3.4.1(@csstools/css-parser-algorithms@4.0.1(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) - '@csstools/css-parser-algorithms': 4.0.1(@csstools/css-tokenizer@4.0.2) - '@csstools/css-tokenizer': 4.0.2 - - '@csstools/css-parser-algorithms@4.0.1(@csstools/css-tokenizer@4.0.2)': - dependencies: - '@csstools/css-tokenizer': 4.0.2 - - '@csstools/css-syntax-patches-for-csstree@1.1.14(css-tree@3.2.1)': - optionalDependencies: - css-tree: 3.2.1 - - '@csstools/css-tokenizer@4.0.2': {} - '@dimforge/rapier3d-compat@0.12.0': {} '@dotenvx/dotenvx@1.75.1': @@ -5414,10 +4886,6 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true - '@exodus/bytes@1.16.0(@noble/hashes@1.8.0)': - optionalDependencies: - '@noble/hashes': 1.8.0 - '@floating-ui/core@1.8.0': dependencies: '@floating-ui/utils': 0.2.12 @@ -5559,33 +5027,6 @@ snapshots: '@img/sharp-win32-x64@0.35.5': optional: true - '@inquirer/ansi@2.0.8': {} - - '@inquirer/confirm@6.3.2(@types/node@26.6.3)': - dependencies: - '@inquirer/core': 12.0.3(@types/node@26.6.3) - '@inquirer/type': 4.1.1(@types/node@26.6.3) - optionalDependencies: - '@types/node': 26.6.3 - - '@inquirer/core@12.0.3(@types/node@26.6.3)': - dependencies: - '@inquirer/ansi': 2.0.8 - '@inquirer/figures': 2.0.9 - '@inquirer/type': 4.1.1(@types/node@26.6.3) - cli-width: 4.1.0 - fast-wrap-ansi: 0.2.2 - mute-stream: 3.0.0 - signal-exit: 4.1.0 - optionalDependencies: - '@types/node': 26.6.3 - - '@inquirer/figures@2.0.9': {} - - '@inquirer/type@4.1.1(@types/node@26.6.3)': - optionalDependencies: - '@types/node': 26.6.3 - '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -5657,15 +5098,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@mswjs/interceptors@0.41.9': - dependencies: - '@open-draft/deferred-promise': 2.2.0 - '@open-draft/logger': 0.3.0 - '@open-draft/until': 2.1.0 - is-node-process: 1.2.0 - outvariant: 1.4.3 - strict-event-emitter: 0.5.1 - '@next/env@16.3.6': {} '@next/swc-darwin-arm64@16.3.6': @@ -5692,9 +5124,6 @@ snapshots: '@next/swc-win32-x64-msvc@16.3.6': optional: true - '@noble/hashes@1.8.0': - optional: true - '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -5707,17 +5136,6 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.3 - '@open-draft/deferred-promise@2.2.0': {} - - '@open-draft/deferred-promise@3.0.0': {} - - '@open-draft/logger@0.3.0': - dependencies: - is-node-process: 1.2.0 - outvariant: 1.4.3 - - '@open-draft/until@2.1.0': {} - '@oxc-project/types@0.151.0': {} '@oxfmt/binding-android-arm-eabi@0.70.0': @@ -6754,31 +6172,6 @@ snapshots: tailwindcss: 4.3.3 vite: 8.3.1(@types/node@26.6.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1) - '@testing-library/dom@10.4.1': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/runtime': 7.29.7 - '@types/aria-query': 5.0.4 - aria-query: 5.3.0 - dom-accessibility-api: 0.5.16 - lz-string: 1.5.0 - picocolors: 1.1.1 - pretty-format: 27.5.1 - - '@testing-library/react@16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': - dependencies: - '@babel/runtime': 7.29.7 - '@testing-library/dom': 10.4.1 - react: 19.3.0 - react-dom: 19.3.0(react@19.3.0) - optionalDependencies: - '@types/react': 19.3.0 - '@types/react-dom': 19.3.0(@types/react@19.3.0) - - '@testing-library/user-event@14.6.7(@testing-library/dom@10.4.1)': - dependencies: - '@testing-library/dom': 10.4.1 - '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 @@ -6787,19 +6180,10 @@ snapshots: '@tweenjs/tween.js@23.1.3': {} - '@types/aria-query@5.0.4': {} - - '@types/chai@5.2.3': - dependencies: - '@types/deep-eql': 4.0.2 - assertion-error: 2.0.1 - '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 - '@types/deep-eql@4.0.2': {} - '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.9 @@ -6830,14 +6214,8 @@ snapshots: dependencies: csstype: 3.2.3 - '@types/set-cookie-parser@2.4.10': - dependencies: - '@types/node': 26.6.3 - '@types/stats.js@0.17.4': {} - '@types/statuses@2.0.6': {} - '@types/three@0.186.0': dependencies: '@dimforge/rapier3d-compat': 0.12.0 @@ -6922,18 +6300,6 @@ snapshots: '@rolldown/pluginutils': 1.0.1 vite: 8.3.1(@types/node@26.6.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1) - '@vitest/mocker@5.0.2(msw@2.15.0(@types/node@26.6.3)(typescript@7.0.2))(vite@8.3.1(@types/node@26.6.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1))': - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - '@vitest/spy': 5.0.2 - estree-walker: 3.0.3 - magic-string: 1.4.2 - optionalDependencies: - msw: 2.15.0(@types/node@26.6.3)(typescript@7.0.2) - vite: 8.3.1(@types/node@26.6.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1) - - '@vitest/spy@5.0.2': {} - '@yuku-analyzer/binding-android-arm64@0.10.2': optional: true @@ -7010,24 +6376,12 @@ snapshots: ansi-regex@6.4.0: {} - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - ansi-styles@5.2.0: {} - argparse@2.0.1: {} aria-hidden@1.2.6: dependencies: tslib: 2.8.1 - aria-query@5.3.0: - dependencies: - dequal: 2.0.3 - - assertion-error@2.0.1: {} - ast-types@0.16.3: dependencies: tslib: 2.8.1 @@ -7054,10 +6408,6 @@ snapshots: baseline-browser-mapping@2.11.26: {} - bidi-js@1.1.0: - dependencies: - require-from-string: 2.0.2 - body-parser@2.3.0: dependencies: bytes: 3.1.2 @@ -7110,8 +6460,6 @@ snapshots: ccount@2.0.1: {} - chai@6.2.2: {} - chalk@5.6.2: {} character-entities-html4@2.1.0: {} @@ -7136,16 +6484,8 @@ snapshots: cli-spinners@2.9.2: {} - cli-width@4.1.0: {} - client-only@0.0.1: {} - cliui@8.0.1: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - clsx@2.1.1: {} cn@0.2.6: {} @@ -7156,12 +6496,6 @@ snapshots: collapse-white-space@2.1.0: {} - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -7221,22 +6555,10 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - css-tree@3.2.1: - dependencies: - mdn-data: 2.27.1 - source-map-js: 1.2.1 - cssesc@3.0.0: {} csstype@3.2.3: {} - data-urls@7.0.0(@noble/hashes@1.8.0): - dependencies: - whatwg-mimetype: 5.0.0 - whatwg-url: 16.0.1(@noble/hashes@1.8.0) - transitivePeerDependencies: - - '@noble/hashes' - debounce-fn@4.0.0: dependencies: mimic-fn: 3.1.0 @@ -7245,8 +6567,6 @@ snapshots: dependencies: ms: 2.1.3 - decimal.js@10.6.0: {} - decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -7282,8 +6602,6 @@ snapshots: diff@8.0.4: {} - dom-accessibility-api@0.5.16: {} - dot-prop@6.0.1: dependencies: is-obj: 2.0.0 @@ -7302,8 +6620,6 @@ snapshots: emoji-regex@10.6.0: {} - emoji-regex@8.0.0: {} - encodeurl@2.0.0: {} enhanced-resolve@5.25.1: @@ -7318,8 +6634,6 @@ snapshots: entities@6.0.1: {} - entities@8.1.0: {} - env-paths@2.2.1: {} error-ex@1.3.4: @@ -7330,8 +6644,6 @@ snapshots: es-errors@1.3.0: {} - es-module-lexer@2.3.2: {} - es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 @@ -7466,8 +6778,6 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.2.0 - expect-type@1.4.0: {} - express-rate-limit@8.7.0(express@5.2.1): dependencies: debug: 4.4.3 @@ -7521,18 +6831,8 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 - fast-string-truncated-width@3.0.3: {} - - fast-string-width@3.0.2: - dependencies: - fast-string-truncated-width: 3.0.3 - fast-uri@3.1.8: {} - fast-wrap-ansi@0.2.2: - dependencies: - fast-string-width: 3.0.2 - fastq@1.20.3: dependencies: reusify: 1.1.0 @@ -7708,8 +7008,6 @@ snapshots: gensync@1.0.0-beta.2: {} - get-caller-file@2.0.5: {} - get-east-asian-width@1.7.0: {} get-intrinsic@1.3.0: @@ -7751,8 +7049,6 @@ snapshots: graceful-fs@4.2.11: {} - graphql@16.14.2: {} - has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -7871,19 +7167,8 @@ snapshots: property-information: 7.2.0 space-separated-tokens: 2.0.2 - headers-polyfill@5.0.1: - dependencies: - '@types/set-cookie-parser': 2.4.10 - set-cookie-parser: 3.1.2 - hono@4.13.9: {} - html-encoding-sniffer@7.0.0(@noble/hashes@1.8.0): - dependencies: - '@exodus/bytes': 1.16.0(@noble/hashes@1.8.0) - transitivePeerDependencies: - - '@noble/hashes' - html-parse-stringify@4.0.1: {} html-void-elements@3.0.0: {} @@ -7947,8 +7232,6 @@ snapshots: is-extglob@2.1.1: {} - is-fullwidth-code-point@3.0.0: {} - is-glob@4.0.3: dependencies: is-extglob: 2.1.1 @@ -7963,8 +7246,6 @@ snapshots: is-interactive@2.0.0: {} - is-node-process@1.2.0: {} - is-number@7.0.0: {} is-obj@2.0.0: {} @@ -7973,8 +7254,6 @@ snapshots: is-plain-obj@4.1.0: {} - is-potential-custom-element-name@1.0.1: {} - is-promise@4.0.0: {} is-regexp@3.1.0: {} @@ -8009,31 +7288,6 @@ snapshots: dependencies: argparse: 2.0.1 - jsdom@30.1.1(@noble/hashes@1.8.0): - dependencies: - '@asamuzakjp/css-color': 7.1.2 - '@asamuzakjp/dom-selector': 9.2.2 - '@bramus/specificity': 2.4.2 - '@csstools/css-syntax-patches-for-csstree': 1.1.14(css-tree@3.2.1) - '@exodus/bytes': 1.16.0(@noble/hashes@1.8.0) - css-tree: 3.2.1 - data-urls: 7.0.0(@noble/hashes@1.8.0) - decimal.js: 10.6.0 - html-encoding-sniffer: 7.0.0(@noble/hashes@1.8.0) - is-potential-custom-element-name: 1.0.1 - lru-cache: 11.5.3 - parse5: 8.0.1 - saxes: 6.0.0 - tough-cookie: 6.0.2 - undici: 8.11.2 - w3c-xmlserializer: 6.0.0 - webidl-conversions: 8.0.1 - whatwg-mimetype: 5.0.0 - whatwg-url: 17.1.2(@noble/hashes@1.8.0) - xml-name-validator: 5.0.0 - transitivePeerDependencies: - - '@noble/hashes' - jsesc@3.1.0: {} json-parse-even-better-errors@2.3.1: {} @@ -8168,8 +7422,6 @@ snapshots: longest-streak@3.1.0: {} - lru-cache@11.5.3: {} - lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -8178,8 +7430,6 @@ snapshots: dependencies: react: 19.3.0 - lz-string@1.5.0: {} - magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -8359,8 +7609,6 @@ snapshots: dependencies: '@types/mdast': 4.0.4 - mdn-data@2.27.1: {} - media-typer@1.1.1: {} merge-descriptors@2.0.0: {} @@ -8686,33 +7934,6 @@ snapshots: ms@2.1.3: {} - msw@2.15.0(@types/node@26.6.3)(typescript@7.0.2): - dependencies: - '@inquirer/confirm': 6.3.2(@types/node@26.6.3) - '@mswjs/interceptors': 0.41.9 - '@open-draft/deferred-promise': 3.0.0 - '@types/statuses': 2.0.6 - cookie: 1.1.1 - graphql: 16.14.2 - headers-polyfill: 5.0.1 - is-node-process: 1.2.0 - outvariant: 1.4.3 - path-to-regexp: 6.3.0 - picocolors: 1.1.1 - rettime: 0.11.12 - statuses: 2.0.2 - strict-event-emitter: 0.5.1 - tough-cookie: 6.0.2 - type-fest: 5.10.0 - until-async: 3.0.2 - yargs: 17.7.3 - optionalDependencies: - typescript: 7.0.2 - transitivePeerDependencies: - - '@types/node' - - mute-stream@3.0.0: {} - nanoid@3.3.19: {} negotiator@1.1.0: @@ -8774,8 +7995,6 @@ snapshots: object-treeify@1.1.33: {} - obug@2.2.1: {} - on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -8827,8 +8046,6 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.2.0 - outvariant@1.4.3: {} - oxfmt@0.70.0: dependencies: tinypool: 2.1.2 @@ -8912,10 +8129,6 @@ snapshots: dependencies: entities: 6.0.1 - parse5@8.0.1: - dependencies: - entities: 8.1.0 - parseurl@1.3.3: {} path-browserify@1.0.1: {} @@ -8926,8 +8139,6 @@ snapshots: path-key@4.0.0: {} - path-to-regexp@6.3.0: {} - path-to-regexp@8.4.2: {} picocolors@1.1.1: {} @@ -8963,12 +8174,6 @@ snapshots: powershell-utils@0.2.1: {} - pretty-format@27.5.1: - dependencies: - ansi-regex: 5.0.1 - ansi-styles: 5.2.0 - react-is: 17.0.2 - pretty-ms@9.3.1: dependencies: parse-ms: 4.0.0 @@ -8987,8 +8192,6 @@ snapshots: proxy-from-env@2.1.0: {} - punycode@2.3.1: {} - qs@6.16.0: dependencies: es-define-property: 1.0.1 @@ -9084,8 +8287,6 @@ snapshots: react-dom: 19.3.0(react@19.3.0) typescript: 7.0.2 - react-is@17.0.2: {} - react-remove-scroll-bar@2.3.8(@types/react@19.3.0)(react@19.3.0): dependencies: react: 19.3.0 @@ -9242,8 +8443,6 @@ snapshots: transitivePeerDependencies: - supports-color - require-directory@2.1.1: {} - require-from-string@2.0.2: {} reselect@5.3.0: {} @@ -9255,8 +8454,6 @@ snapshots: onetime: 7.0.0 signal-exit: 4.1.0 - rettime@0.11.12: {} - reusify@1.1.0: {} rolldown@1.2.11: @@ -9298,10 +8495,6 @@ snapshots: safer-buffer@2.1.2: {} - saxes@6.0.0: - dependencies: - xmlchars: 2.2.0 - scheduler@0.28.0: {} scroll-into-view-if-needed@3.1.0: @@ -9339,8 +8532,6 @@ snapshots: set-cookie-parser@2.7.2: {} - set-cookie-parser@3.1.2: {} - setprototypeof@1.2.0: {} shadcn@4.21.0(typescript@7.0.2): @@ -9493,18 +8684,8 @@ snapshots: statuses@2.0.2: {} - std-env@4.2.0: {} - stdin-discarder@0.2.2: {} - strict-event-emitter@0.5.1: {} - - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - string-width@7.2.0: dependencies: emoji-regex: 10.6.0 @@ -9559,8 +8740,6 @@ snapshots: systeminformation@5.33.13: {} - tagged-tag@1.0.0: {} - tailwind-merge@3.7.0: {} tailwindcss@4.3.3: {} @@ -9571,8 +8750,6 @@ snapshots: tiny-invariant@1.3.3: {} - tinybench@6.2.0: {} - tinyexec@1.3.1: {} tinyglobby@0.2.17: @@ -9582,26 +8759,12 @@ snapshots: tinypool@2.1.2: {} - tldts-core@7.4.16: {} - - tldts@7.4.16: - dependencies: - tldts-core: 7.4.16 - to-regex-range@5.0.1: dependencies: is-number: 7.0.0 toidentifier@1.0.1: {} - tough-cookie@6.0.2: - dependencies: - tldts: 7.4.16 - - tr46@6.0.0: - dependencies: - punycode: 2.3.1 - trim-lines@3.0.1: {} trough@2.2.0: {} @@ -9621,10 +8784,6 @@ snapshots: tw-animate-css@1.4.0: {} - type-fest@5.10.0: - dependencies: - tagged-tag: 1.0.0 - type-is@2.1.0: dependencies: content-type: 2.1.0 @@ -9658,8 +8817,6 @@ snapshots: undici@7.30.0: {} - undici@8.11.2: {} - unicorn-magic@0.3.0: {} unified@11.0.5: @@ -9708,8 +8865,6 @@ snapshots: unpipe@1.0.0: {} - until-async@3.0.2: {} - update-browserslist-db@1.3.3(browserslist@4.29.1): dependencies: browserslist: 4.29.1 @@ -9770,54 +8925,8 @@ snapshots: jiti: 2.7.0 yaml: 2.9.1 - vitest@5.0.2(@types/node@26.6.3)(jsdom@30.1.1(@noble/hashes@1.8.0))(msw@2.15.0(@types/node@26.6.3)(typescript@7.0.2))(vite@8.3.1(@types/node@26.6.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)): - dependencies: - '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.2(msw@2.15.0(@types/node@26.6.3)(typescript@7.0.2))(vite@8.3.1(@types/node@26.6.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1)) - chai: 6.2.2 - es-module-lexer: 2.3.2 - expect-type: 1.4.0 - magic-string: 1.4.2 - obug: 2.2.1 - picomatch: 4.0.7 - std-env: 4.2.0 - tinybench: 6.2.0 - tinyexec: 1.3.1 - tinyglobby: 0.2.17 - vite: 8.3.1(@types/node@26.6.3)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.1) - why-is-node-running: 3.2.2 - optionalDependencies: - '@types/node': 26.6.3 - jsdom: 30.1.1(@noble/hashes@1.8.0) - transitivePeerDependencies: - - msw - - w3c-xmlserializer@6.0.0: - dependencies: - xml-name-validator: 5.0.0 - web-namespaces@2.0.1: {} - webidl-conversions@8.0.1: {} - - whatwg-mimetype@5.0.0: {} - - whatwg-url@16.0.1(@noble/hashes@1.8.0): - dependencies: - '@exodus/bytes': 1.16.0(@noble/hashes@1.8.0) - tr46: 6.0.0 - webidl-conversions: 8.0.1 - transitivePeerDependencies: - - '@noble/hashes' - - whatwg-url@17.1.2(@noble/hashes@1.8.0): - dependencies: - '@exodus/bytes': 1.16.0(@noble/hashes@1.8.0) - tr46: 6.0.0 - webidl-conversions: 8.0.1 - transitivePeerDependencies: - - '@noble/hashes' - which@2.0.2: dependencies: isexe: 2.0.0 @@ -9826,14 +8935,6 @@ snapshots: dependencies: isexe: 3.1.5 - why-is-node-running@3.2.2: {} - - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrappy@1.0.2: {} wsl-utils@1.0.0: @@ -9841,28 +8942,10 @@ snapshots: is-wsl: 3.1.1 powershell-utils: 0.1.0 - xml-name-validator@5.0.0: {} - - xmlchars@2.2.0: {} - - y18n@5.0.8: {} - yallist@3.1.1: {} yaml@2.9.1: {} - yargs-parser@21.1.1: {} - - yargs@17.7.3: - dependencies: - cliui: 8.0.1 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 21.1.1 - yocto-spinner@1.2.2: dependencies: yoctocolors: 2.2.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 642ed9c6..a317a066 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,5 @@ packages: allowBuilds: esbuild: true - msw: true sharedWorkspaceLockfile: true diff --git a/vercel.json b/vercel.json deleted file mode 100644 index 60b4c053..00000000 --- a/vercel.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "$schema": "https://openapi.vercel.sh/vercel.json", - "framework": "nextjs", - "installCommand": "pnpm --dir apps/docs install --frozen-lockfile", - "buildCommand": "pnpm --dir apps/docs build", - "redirects": [ - { - "source": "/:path*", - "has": [ - { - "type": "host", - "value": "1cli.dev" - } - ], - "destination": "https://www.1cli.dev/:path*", - "permanent": true - } - ], - "headers": [ - { - "source": "/install.sh", - "headers": [ - { - "key": "Cache-Control", - "value": "public, max-age=300, must-revalidate" - }, - { - "key": "Content-Type", - "value": "text/plain; charset=utf-8" - } - ] - } - ] -} From ff26849545488ad795ecdea69d821b53f232117a Mon Sep 17 00:00:00 2001 From: caorushizi <84996057@qq.com> Date: Sat, 3 Oct 2026 22:43:25 +0800 Subject: [PATCH 2/3] fix: ensure workspace registration for create and serve --- ARCHITECTURE.md | 8 +++--- README.md | 2 +- apps/dashboard/src/locales/en-US.json | 2 +- apps/dashboard/src/locales/zh-CN.json | 2 +- apps/docs/content/docs/en/create.md | 2 ++ apps/docs/content/docs/en/serve.md | 4 +++ apps/docs/content/docs/zh/create.md | 2 ++ apps/docs/content/docs/zh/serve.md | 4 +++ .../cli/internal/modules/creation/service.go | 18 ++++++------- .../internal/platform/i18n/locales/en-US.json | 7 ++--- .../internal/platform/i18n/locales/zh-CN.json | 7 ++--- .../internal/transport/cobra/create/create.go | 6 ++--- .../cli/internal/transport/cobra/serve/cmd.go | 26 +++++++------------ .../cli/internal/transport/http/middleware.go | 12 ++++++++- 14 files changed, 60 insertions(+), 42 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1ba8fd4e..df4685d4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -271,9 +271,11 @@ Historical manifest-mutation route paths fail closed with HTTP 409 and `SERVE_REPOSITORY_READ_ONLY`; they never silently ignore a requested write. Workspace discovery across invocations is a separate machine-local registry, -not Profile state and not Kernel state. `one create` observes a Workspace only -after successful creation; `one serve` observes the nearest manifest found by -walking up from its launch directory. Both update the XDG-aware +not Profile state and not Kernel state. `one create` registers a Workspace as soon +as its generated manifest is valid, before optional tooling and skills setup; +`one serve` registers the nearest manifest found by walking up from its launch +directory. With no enclosing manifest, it opens the global Dashboard and loads +the existing registry. Both update the XDG-aware `workspaces.json` through `application/workspace.RegistryService` and the local registry adapter. The registry stores only an opaque local entry id, manifest identity, canonical root, display name, and observation timestamps. Projects, diff --git a/README.md b/README.md index 5c3711aa..48b719f8 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ The assistant can read `one.manifest.toml` and project README files, then use On One CLI manages variables in Infisical and injects them directly into commands. Workspace bindings live in the manifest `[env.infisical]` table; `.env` files are not loaded or exported. Run `one login` to sign in with your browser; the single session is stored in the OS keyring, with no plaintext fallback. Use `one whoami` to inspect status and `one logout` to remove the local session. -Run `one serve` for account settings, workspaces, and shared credentials. Workspace binding changes use a reviewed, revision-checked TOML draft; project settings display derived configuration. Remote variable edits take effect immediately; lists omit values and reveal/copy fetch plaintext only on demand. +Run `one serve` from any directory for account settings, workspaces, and shared credentials. `one create` registers new workspaces automatically; running `one serve` inside an existing workspace or its subdirectories registers it and opens its page. Workspace binding changes use a reviewed, revision-checked TOML draft; project settings display derived configuration. Remote variable edits take effect immediately; lists omit values and reveal/copy fetch plaintext only on demand. Choose shared credential storage with `one env bind --global`. Agents discover environments and folders through `one env --global` and `one env list --global`, then execute with `one exec --global --env dev --path /folder --keys KEY -- command`. One does not print injected values. Child logs preserve their original content and formatting. diff --git a/apps/dashboard/src/locales/en-US.json b/apps/dashboard/src/locales/en-US.json index 4586963f..cd0b45a6 100644 --- a/apps/dashboard/src/locales/en-US.json +++ b/apps/dashboard/src/locales/en-US.json @@ -56,7 +56,7 @@ "emptyTitle": "No Workspaces yet", "emptyDescription": "Run one create to create a Workspace, or run one serve inside an existing Workspace to register it.", "listLabel": "Registered Workspaces", - "registrationHint": "New Workspaces appear here after you run one serve.", + "registrationHint": "one create registers new workspaces automatically. Run one serve inside an existing workspace or its subdirectories to register it.", "refresh": "Refresh", "search": "Search name, path or ID…", "clearSearch": "Clear search", diff --git a/apps/dashboard/src/locales/zh-CN.json b/apps/dashboard/src/locales/zh-CN.json index d3c71d6f..a5a32716 100644 --- a/apps/dashboard/src/locales/zh-CN.json +++ b/apps/dashboard/src/locales/zh-CN.json @@ -56,7 +56,7 @@ "emptyTitle": "尚未登记工作区", "emptyDescription": "运行 one create 创建工作区,或在已有工作区中运行 one serve 进行登记。", "listLabel": "已登记的工作区", - "registrationHint": "运行 one serve 后,新的 Workspace 会显示在这里。", + "registrationHint": "one create 自动登记新工作区;在已有工作区或其子目录运行 one serve,也会自动登记。", "refresh": "刷新", "search": "搜索名称、路径或 ID…", "clearSearch": "清除搜索", diff --git a/apps/docs/content/docs/en/create.md b/apps/docs/content/docs/en/create.md index c62b6122..3025943f 100644 --- a/apps/docs/content/docs/en/create.md +++ b/apps/docs/content/docs/en/create.md @@ -5,6 +5,8 @@ description: Create a new One workspace root skeleton. `one create` creates an empty workspace. It does not ask for projects or deployment. It installs project-scoped development skills by default; global AI-tool settings are preserved. Add projects later with `one add`. +Once a valid workspace is generated, One registers it in the local workspace list before preparing tools, Git hooks, and skills. Run `one serve` from any directory to open Dashboard afterward. A registration failure preserves the workspace and reports the reason in text output or the structured `warnings` field. Running `one serve` inside that workspace or its subdirectories retries registration. + ## Usage ```bash diff --git a/apps/docs/content/docs/en/serve.md b/apps/docs/content/docs/en/serve.md index e20b8d73..f02531aa 100644 --- a/apps/docs/content/docs/en/serve.md +++ b/apps/docs/content/docs/en/serve.md @@ -8,6 +8,10 @@ one serve one serve --port 0 --open=false ``` +Run `one serve` from any directory. Inside a workspace or its subdirectories, One finds the nearest `one.manifest.toml`, registers that workspace, and opens its page. From an ordinary directory, it opens the local workspace list. Repeated launches update the existing entry. + +`one create` automatically registers new workspaces. Registration is stored locally in `~/.config/one/workspaces.json`, respecting `XDG_CONFIG_HOME`. You can then start Dashboard from any directory to access those workspaces. + The server only binds to loopback addresses. The sidebar contains Workspaces, Shared credentials, and Settings. All pages share the current Infisical account. Settings supports browser login, pending state, reopening, cancellation, logout, and custom instances. Shared credentials can initialize a default location, create a Secret Manager project, or select an existing project, then browse its environments and folders. Lists contain metadata only. Reveal, copy, edit, and delete are explicit actions; remote writes are immediate and deletion identifies the complete target scope. diff --git a/apps/docs/content/docs/zh/create.md b/apps/docs/content/docs/zh/create.md index 6ef1652d..974fed90 100644 --- a/apps/docs/content/docs/zh/create.md +++ b/apps/docs/content/docs/zh/create.md @@ -5,6 +5,8 @@ description: 起一个新的 one 工作区根骨架。 `one create` 只创建空工作区:不问项目、不问部署,默认安装项目级开发 skills,保留本机 AI 工具的全局配置。需要项目时使用 `one add`。 +生成有效工作区后,One 会立即将其登记到本机工作区列表,再准备工具、Git hooks 和 skills。随后可在任意目录运行 `one serve` 打开 Dashboard。登记失败会保留工作区,并在文本输出或结构化输出的 `warnings` 中给出具体原因;在该工作区或其子目录运行 `one serve` 会再次尝试登记。 + ## 用法 ```bash diff --git a/apps/docs/content/docs/zh/serve.md b/apps/docs/content/docs/zh/serve.md index 3f00ebc3..9a629c66 100644 --- a/apps/docs/content/docs/zh/serve.md +++ b/apps/docs/content/docs/zh/serve.md @@ -8,6 +8,10 @@ one serve one serve --port 0 --open=false ``` +可在任意目录运行 `one serve`。在工作区根目录或其子目录启动时,One 会向上查找最近的 `one.manifest.toml`,自动登记该工作区并打开它的页面;在普通目录启动时,打开本机工作区列表。重复启动会更新已有记录。 + +`one create` 会自动登记新工作区。登记信息保存在本机 `~/.config/one/workspaces.json`,支持 `XDG_CONFIG_HOME`;之后可在任意目录启动 Dashboard 访问这些工作区。 + 服务只允许回环地址。侧栏提供工作区、共享凭据和设置;所有页面共用当前 Infisical 账号。设置页打开浏览器登录,支持等待、重新打开、取消、退出和自定义实例。 共享凭据页支持一键初始化默认位置、新建项目或选择已有 Secret Manager 项目作为存放位置,按环境与目录浏览名称和说明。列表不包含明文;查看、复制、编辑和删除均显式操作。远端修改立即生效,删除会显示完整目标范围。 diff --git a/packages/cli/internal/modules/creation/service.go b/packages/cli/internal/modules/creation/service.go index 3f9dba58..2bfa037a 100644 --- a/packages/cli/internal/modules/creation/service.go +++ b/packages/cli/internal/modules/creation/service.go @@ -31,21 +31,19 @@ type SkillInstaller interface { Install(context.Context, string, []skillsmodule.Selection) []string } -// WorkspaceObserver is the optional machine-local discovery hook invoked -// after a workspace has been created successfully. Discovery is auxiliary: -// a registry failure must never roll back an otherwise valid workspace. +// WorkspaceObserver records each newly created workspace in the machine-local +// registry. A registry failure must never roll back an otherwise valid workspace. type WorkspaceObserver func(context.Context, string, string) error func NewService( environments *environmentmodule.Service, - observers ...WorkspaceObserver, + observer WorkspaceObserver, ) (*Service, error) { if environments == nil { return nil, errors.New(i18n.T("creation.environment_required")) } - var observer WorkspaceObserver - if len(observers) > 0 { - observer = observers[0] + if observer == nil { + return nil, errors.New(i18n.T("creation.workspace_observer_required")) } return &Service{environments: environments, observer: observer}, nil } @@ -148,6 +146,9 @@ func (s *Service) CreateWorkspace(ctx context.Context, input WorkspaceInput) (Wo if err != nil { return result, err } + // Register the durable workspace before preparing local tooling and skills. + result.RegistryWarn = s.observer(ctx, input.TargetDir, "create") + files := fsutil.NewFilePlan(input.TargetDir) if err := hooks.PlanFiles(files, manifest); err != nil { return result, err @@ -182,9 +183,6 @@ func (s *Service) CreateWorkspace(ctx context.Context, input WorkspaceInput) (Wo } result.HooksWarn = err } - if s.observer != nil { - result.RegistryWarn = s.observer(ctx, input.TargetDir, "create") - } if !input.SkipSkills && s.Skills != nil { result.SkillsWarnings = s.Skills.Install(ctx, input.TargetDir, skillsmodule.Defaults("", "")) } diff --git a/packages/cli/internal/platform/i18n/locales/en-US.json b/packages/cli/internal/platform/i18n/locales/en-US.json index 1b0f1c46..81d9e5e4 100644 --- a/packages/cli/internal/platform/i18n/locales/en-US.json +++ b/packages/cli/internal/platform/i18n/locales/en-US.json @@ -74,7 +74,7 @@ "locale_from_env": " (from $LANG / $LC_*)", "locale_path": "Preferences file: %s", "create.short": "Create a workspace", - "create.tip": "Create an empty workspace named after its target folder. Empty directories and empty Git repositories are supported. Run one add afterward to add a project. Installs one-cli usage and find-skills discovery guidance in the workspace's .agents/skills by default; use --skip-skills to skip. Business skills are installed manually.", + "create.tip": "Create an empty workspace named after its target folder. Empty directories and empty Git repositories are supported. Automatically registers the new workspace in the local workspace list. Run one serve from any directory to manage it, or one add to add a project.\n\nInstalls one-cli usage and find-skills discovery guidance in the workspace's .agents/skills by default; use --skip-skills to skip. Business skills are installed manually.", "create.success": "✓ Workspace created: %s", "create.location": " Location: %s", "create.package_manager": " Package manager: %s", @@ -143,7 +143,7 @@ "serve.started": "✓ Local settings page started: %s", "serve.instructions": " Manage projects, development services, and connections in the browser; press Ctrl-C to stop all services.", "serve.browser_failed": " Could not open the browser automatically (%v); copy the URL above.", - "serve.registry_warning": "The workspace list was not updated (%v); Dashboard will still start, but this workspace may be absent", + "serve.registry_warning": "Could not discover or register the current workspace (%v); Dashboard will still start with the existing local workspace list", "serve.flag.host": "Loopback host for the local settings page", "serve.flag.port": "Local port (0 chooses an available port)", "serve.flag.open": "Open the settings page in the default browser", @@ -302,7 +302,7 @@ "exec.control_variable": "The shared folder contains process-control variable %s. Use --keys to select application credentials before injection.", "locale.tip": "Shows the saved language preference and the language active in this terminal. Pass auto, zh-CN, or en-US to change the preference.\n\nauto follows LC_ALL, LC_MESSAGES, then LANG; the fallback is English. The Dashboard shares this preference. Project and third-party tool logs keep their original language.", "exec.tip": "Runs a command in the selected project directory. Select a project by name or relative path, or run from inside a project. The positional project and --project must agree when both are used.\n\nUse -- to separate One options from the child command. When the workspace has an env.infisical binding, variables are fetched from Infisical and injected into the child process. --env selects the environment. Injected values override same-name shell variables. Without a binding, the shell environment is used directly. Local .env files are not loaded by One.\n\nWith --global, provide --env and --path explicitly. The command keeps the current working directory. Use --keys to select shared credentials.\n\nOne does not print injected variable values. Child stdout and stderr pass through unchanged.", - "serve.tip": "Starts a local Dashboard for workspaces, development services, project configuration, Infisical login, and shared credentials. The project Run tab provides start, stop, restart, a live console, and local access links. Review manifest changes before saving. Plaintext variables are fetched only when viewed or copied.\n\nBy default, binds to 127.0.0.1 on an available port and opens your browser. Closing the page keeps services running; Ctrl-C stops the Dashboard and its services. Host and Origin checks protect the interface.", + "serve.tip": "Starts a local Dashboard from any directory for registered workspaces, development services, project configuration, Infisical login, and shared credentials. Inside a workspace or its subdirectories, automatically registers that workspace and opens its page. Elsewhere, opens the local workspace list. The project Run tab provides start, stop, restart, a live console, and local access links. Review manifest changes before saving. Plaintext variables are fetched only when viewed or copied.\n\nBy default, binds to 127.0.0.1 on an available port and opens your browser. Closing the page keeps services running; Ctrl-C stops the Dashboard and its services. Host and Origin checks protect the interface.", "template.compat.project": "Workspace setting %s/%s is incompatible with project %q (template %s); this template supports %s backends: [%s].", "template.compat.workspace": "Workspace setting %s/%s is incompatible with template %s; this template supports %s backends: [%s]. Switch the manifest to %s, or keep %s for other projects.", "templates.empty": "No templates registered.", @@ -365,6 +365,7 @@ "creation.template_parent_path": "Invalid local template %s. Parent paths (..) are not allowed.", "creation.category_unknown": "Unknown template category: %s", "creation.environment_required": "creation: environment service is required", + "creation.workspace_observer_required": "creation: workspace registration service is required", "creation.target_not_empty": "Cannot create a workspace in %s.\nThe directory already contains: %s. Choose an empty directory or one containing only Git metadata.", "creation.nested_workspace": "Cannot create another workspace inside %s. Use one add to add a project, or choose a directory outside this workspace.", "creation.workspace_name_invalid": "Cannot use workspace name %q. Start with a letter or number and use only letters, numbers, hyphens, and underscores.", diff --git a/packages/cli/internal/platform/i18n/locales/zh-CN.json b/packages/cli/internal/platform/i18n/locales/zh-CN.json index a729ce09..c904636b 100644 --- a/packages/cli/internal/platform/i18n/locales/zh-CN.json +++ b/packages/cli/internal/platform/i18n/locales/zh-CN.json @@ -74,7 +74,7 @@ "locale_from_env": "(来自 $LANG / $LC_*)", "locale_path": "偏好设置文件:%s", "create.short": "创建工作区", - "create.tip": "创建空工作区,名称自动使用目标文件夹名。支持空目录或仅包含 Git 元数据的仓库。创建后运行 one add 添加项目。 默认在工作区的 .agents/skills 中安装 one-cli 用法指导和 find-skills 查找指导,使用 --skip-skills 跳过。业务 skills 由用户手动安装。", + "create.tip": "创建空工作区,名称自动使用目标文件夹名。支持空目录或仅包含 Git 元数据的仓库。工作区创建后自动登记到本机工作区列表,可在任意目录运行 one serve 管理。运行 one add 添加项目。\n\n默认在工作区的 .agents/skills 中安装 one-cli 用法指导和 find-skills 查找指导,使用 --skip-skills 跳过。业务 skills 由用户手动安装。", "create.success": "✓ 工作区已创建:%s", "create.location": " 位置:%s", "create.package_manager": " 包管理器:%s", @@ -143,7 +143,7 @@ "serve.started": "✓ 本地设置页面已启动:%s", "serve.instructions": " 在浏览器中管理项目、开发服务和连接;按 Ctrl-C 停止全部服务。", "serve.browser_failed": " 无法自动打开浏览器(%v);请复制上方 URL。", - "serve.registry_warning": "工作区列表未更新(%v);Dashboard 仍会启动,但本次工作区可能不会出现在列表中", + "serve.registry_warning": "当前工作区识别或登记失败(%v);Dashboard 仍会启动,可查看本机已有工作区", "serve.flag.host": "本地设置页面使用的回环地址", "serve.flag.port": "本地端口(0 表示自动选择可用端口)", "serve.flag.open": "使用默认浏览器打开设置页面", @@ -302,7 +302,7 @@ "exec.control_variable": "全局目录包含进程控制变量 %s,拒绝注入;请通过 --keys 选择业务凭据", "locale.tip": "查看保存的语言偏好和当前终端中生效的语言。传入 auto、zh-CN 或 en-US 可修改偏好。\n\nauto 依次读取 LC_ALL、LC_MESSAGES、LANG,未识别时使用英文。Dashboard 共用这份偏好。项目和第三方工具日志保留原始语言。", "exec.tip": "在选定的项目目录执行命令。可按名称或相对路径选择项目,也可从项目目录内执行;同时提供位置参数和 --project 时,两者必须一致。\n\n使用 -- 分隔 One 参数和子进程命令。工作区配置 env.infisical 绑定后,从 Infisical 获取变量并注入子进程,--env 指定环境;注入值覆盖同名 shell 变量。未配置绑定时,直接使用 shell 环境。One 不读取本地 .env 文件。\n\n使用 --global 时必须明确提供 --env 和 --path,命令保持当前工作目录。可用 --keys 选择共享凭据。\n\nOne 不打印注入的变量值。子进程的标准输出和标准错误原样传递。", - "serve.tip": "启动本地 Dashboard,管理工作区、开发服务、项目配置、Infisical 登录和共享凭据。项目运行页支持启动、停止、重启、实时控制台及本机访问链接。Manifest 修改先审阅再保存;变量明文仅在查看或复制时读取。\n\n默认绑定 127.0.0.1 的可用端口并打开浏览器。关闭页面不影响开发服务;按 Ctrl-C 关闭 Dashboard 及其启动的服务。接口受到 Host 和 Origin 校验保护。", + "serve.tip": "可在任意目录启动本地 Dashboard,管理本机工作区、开发服务、项目配置、Infisical 登录和共享凭据。在工作区或其子目录启动时,自动登记工作区并打开其页面;在其他目录启动时,打开本机工作区列表。项目运行页支持启动、停止、重启、实时控制台及本机访问链接。Manifest 修改先审阅再保存;变量明文仅在查看或复制时读取。\n\n默认绑定 127.0.0.1 的可用端口并打开浏览器。关闭页面不影响开发服务;按 Ctrl-C 关闭 Dashboard 及其启动的服务。接口受到 Host 和 Origin 校验保护。", "template.compat.project": "工作区当前 %s/%s 与 subproject %q(模板 %s)不兼容;该模板的 %s 域支持: [%s]", "template.compat.workspace": "工作区当前 %s/%s 不适用于模板 %s;该模板的 %s 域支持: [%s]。建议手动调整 manifest 切到 %s,或保持现状(仅其他 subproject 走 %s)", "templates.empty": "没有已注册的模板。", @@ -365,6 +365,7 @@ "creation.template_parent_path": "本地模板配置无效:%s。不允许使用 \"..\" 路径。", "creation.category_unknown": "未知模板分类: %s", "creation.environment_required": "需要环境变量服务", + "creation.workspace_observer_required": "需要工作区登记服务", "creation.target_not_empty": "无法在 %s 中创建工作区。\n目录中已有文件:%s。请选择空目录或只有 Git 元数据的目录。", "creation.nested_workspace": "无法在已有工作区 %s 内再创建工作区。请用 one add 添加项目,或选择该工作区之外的目录。", "creation.workspace_name_invalid": "无法使用工作区名称 %q。名称须以字母或数字开头,且只能包含字母、数字、连字符和下划线。", diff --git a/packages/cli/internal/transport/cobra/create/create.go b/packages/cli/internal/transport/cobra/create/create.go index 9b0f01c2..390ecd82 100644 --- a/packages/cli/internal/transport/cobra/create/create.go +++ b/packages/cli/internal/transport/cobra/create/create.go @@ -101,9 +101,6 @@ func runCreate(deps Dependencies, cmd *cobra.Command, rawDir string, flags *crea result.EnvironmentWarn, )) } - if result.RegistryWarn != nil { - prompt.Step(i18n.Tf("create.registry_warning", result.RegistryWarn)) - } // v2 envelope: replaces the v1 `enabled_backends []string` with // per-domain semantic fields. `secrets_backend` names the env @@ -130,6 +127,9 @@ func runCreate(deps Dependencies, cmd *cobra.Command, rawDir string, flags *crea SecretsBackend: secretsBackend, DevEnabled: devEnabled, } + if result.RegistryWarn != nil { + payload.Warnings = append(payload.Warnings, i18n.Tf("create.registry_warning", result.RegistryWarn)) + } if result.MiseTrustWarn != nil { payload.Warnings = append(payload.Warnings, i18n.Tf("creation.mise_trust_warning", result.MiseTrustWarn)) } diff --git a/packages/cli/internal/transport/cobra/serve/cmd.go b/packages/cli/internal/transport/cobra/serve/cmd.go index 46b539c5..3f6450d3 100644 --- a/packages/cli/internal/transport/cobra/serve/cmd.go +++ b/packages/cli/internal/transport/cobra/serve/cmd.go @@ -6,6 +6,7 @@ package servecmd import ( "context" + "errors" "fmt" "io" "net/url" @@ -22,6 +23,7 @@ import ( "github.com/torchstellar-team/one-cli/packages/cli/internal/core/workspace" creationmodule "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/creation" environmentmodule "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/environment" + cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" serve "github.com/torchstellar-team/one-cli/packages/cli/internal/transport/http" @@ -79,10 +81,8 @@ func newServeCmd(deps Dependencies) *cobra.Command { WorkspaceService: deps.Workspaces, RegistryService: deps.Registry, }, func(res serve.Result) { + res.URL = workspaceDashboardURL(res.URL, target.EntryID) output.Emit(res) - if cmd.Name() == "serve" { - res.URL = workspaceDashboardURL(res.URL, target.EntryID) - } maybeOpenBrowser(cmd.ErrOrStderr(), res, open) }) }, @@ -103,6 +103,8 @@ type serveWorkspaceTarget struct { EntryID string } +// discoverServeWorkspaceTarget optionally discovers and registers the enclosing +// workspace. Any directory can launch the Dashboard; discovery failures are warnings. func discoverServeWorkspaceTarget( ctx context.Context, start string, @@ -110,7 +112,11 @@ func discoverServeWorkspaceTarget( ) (serveWorkspaceTarget, error) { root, err := workspace.WalkUpToManifest(start) if err != nil { - return serveWorkspaceTarget{}, nil + var cliErr *output.Error + if errors.As(err, &cliErr) && cliErr.Code == string(cliErrors.NOT_ONE_PROJECT) { + return serveWorkspaceTarget{}, nil + } + return serveWorkspaceTarget{}, err } target := serveWorkspaceTarget{Root: root} if registry == nil { @@ -124,18 +130,6 @@ func discoverServeWorkspaceTarget( return target, nil } -// discoverServeWorkspace keeps optional Workspace discovery and registration -// in one testable boundary. Running outside a Workspace is valid: the server -// still exposes machine-level profiles and the previously observed registry. -func discoverServeWorkspace( - ctx context.Context, - start string, - registry *workspaceapp.RegistryService, -) (string, error) { - target, err := discoverServeWorkspaceTarget(ctx, start, registry) - return target.Root, err -} - func workspaceDashboardURL(baseURL, entryID string) string { if strings.TrimSpace(baseURL) == "" || strings.TrimSpace(entryID) == "" { return baseURL diff --git a/packages/cli/internal/transport/http/middleware.go b/packages/cli/internal/transport/http/middleware.go index 7b5e0c06..c4c1aa8b 100644 --- a/packages/cli/internal/transport/http/middleware.go +++ b/packages/cli/internal/transport/http/middleware.go @@ -14,7 +14,9 @@ package serve // vector. We skip GET to avoid breaking direct browser navigation / // bookmarks. import ( + "context" "encoding/json" + "errors" "io/fs" "net/http" "strings" @@ -26,6 +28,7 @@ import ( "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/devservice" environmentmodule "github.com/torchstellar-team/one-cli/packages/cli/internal/modules/environment" cliErrors "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/errors" + "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/i18n" "github.com/torchstellar-team/one-cli/packages/cli/internal/platform/output" "github.com/torchstellar-team/one-cli/packages/cli/internal/resources/bundled" ) @@ -92,7 +95,14 @@ func BuildMux(opts MuxOpts) http.Handler { opts.EnvironmentService = service } if opts.CreationService == nil { - service, err := creationmodule.NewService(opts.EnvironmentService) + observe := func(ctx context.Context, root, source string) error { + if opts.RegistryService == nil { + return errors.New(i18n.T("creation.workspace_observer_required")) + } + _, err := opts.RegistryService.Observe(ctx, root, source) + return err + } + service, err := creationmodule.NewService(opts.EnvironmentService, observe) if err != nil { panic(err) } From ada6e0f788b14c649f35e6b452963e1bfc77204e Mon Sep 17 00:00:00 2001 From: caorushizi <84996057@qq.com> Date: Sun, 4 Oct 2026 06:24:09 +0800 Subject: [PATCH 3/3] feat(dashboard): streamline workspace and shared credentials Automatically reuse or create shared-credentials after login and restore bindings per account. Simplify project and credentials pages, improve workspace project rebinding, and remove unused service and template APIs. Update Chinese and English copy and documentation. --- README.md | 4 +- apps/dashboard/src/App.tsx | 7 +- apps/dashboard/src/api/services.ts | 36 -- apps/dashboard/src/api/session.ts | 23 +- apps/dashboard/src/components/AppMenu.tsx | 11 +- apps/dashboard/src/components/TopBar.tsx | 5 - apps/dashboard/src/components/ui/button.tsx | 1 + .../features/global-variables/FolderTree.tsx | 215 +++++++ .../global-variables/GlobalVariables.tsx | 412 +++++++------- .../global-variables/LocationPicker.tsx | 292 ---------- .../InfisicalBindingDialog.tsx | 366 ++++++------ .../infisical-session/AccountSettings.tsx | 33 +- .../SharedCredentialsNotice.tsx | 82 +++ .../project-settings/ProjectInspector.tsx | 538 ++++++++---------- .../forms/EnvironmentForm.tsx | 29 +- .../project-settings/forms/FormLayout.tsx | 60 -- .../project-settings/forms/GeneralForm.tsx | 138 ++--- .../src/features/secrets/SecretsManager.tsx | 247 ++++---- .../src/features/services/ServicePanel.tsx | 279 --------- .../src/features/services/console.tsx | 60 -- .../WorkspaceEnvironmentSettings.tsx | 96 ---- .../WorkspaceSettingsDialog.tsx | 109 ---- apps/dashboard/src/locales/en-US.json | 257 +-------- apps/dashboard/src/locales/zh-CN.json | 257 +-------- apps/dashboard/src/pages/Overview.tsx | 3 +- apps/dashboard/src/pages/ProjectTemplates.tsx | 239 -------- apps/dashboard/src/router/routes.tsx | 10 +- apps/dashboard/src/types/api.ts | 34 -- apps/docs/content/docs/en/build.md | 2 +- apps/docs/content/docs/en/env-vars.md | 4 +- apps/docs/content/docs/en/login.md | 8 +- apps/docs/content/docs/en/manifest.md | 1 - apps/docs/content/docs/en/quick-start.md | 4 +- apps/docs/content/docs/en/serve.md | 12 +- apps/docs/content/docs/zh/build.md | 2 +- apps/docs/content/docs/zh/env-vars.md | 4 +- apps/docs/content/docs/zh/login.md | 10 +- apps/docs/content/docs/zh/manifest.md | 1 - apps/docs/content/docs/zh/quick-start.md | 4 +- apps/docs/content/docs/zh/serve.md | 12 +- .../content/tutorials/en/infisical-login.mdx | 4 +- .../content/tutorials/zh/infisical-login.mdx | 6 +- apps/docs/src/app/(home)/home-page.tsx | 22 +- mise.toml | 2 - .../env/infisical/automatic_binding.go | 101 ++++ .../internal/adapters/env/infisical/global.go | 91 ++- .../adapters/env/infisical/shared_location.go | 62 +- .../authentication/shared_credentials.go | 202 +++++++ .../application/execution/operation.go | 31 - .../workspace/environment_settings.go | 2 - .../application/workspace/project_settings.go | 82 +-- .../internal/application/workspace/service.go | 25 +- .../internal/application/workspace/tasks.go | 29 - .../internal/bootstrap/cli/dependencies.go | 13 +- .../internal/modules/devservice/endpoints.go | 67 --- .../internal/modules/devservice/manager.go | 402 ------------- .../internal/modules/environment/global.go | 8 +- .../internal/modules/environment/target.go | 34 -- .../cli/internal/modules/tasks/projection.go | 28 - .../cli/internal/modules/tasks/service.go | 13 +- packages/cli/internal/modules/taskui/ui.go | 5 +- .../internal/platform/i18n/locales/en-US.json | 27 +- .../internal/platform/i18n/locales/zh-CN.json | 27 +- .../platform/infisicalsession/session.go | 46 +- .../internal/platform/localurl/localurl.go | 44 -- .../cli/internal/transport/cobra/auth/cmd.go | 11 +- .../cli/internal/transport/cobra/run/cmd.go | 2 +- .../internal/transport/cobra/run/service.go | 62 -- .../transport/http/handlers_services.go | 123 ---- .../transport/http/handlers_session.go | 32 +- .../http/handlers_workspace_mutate.go | 34 -- .../transport/http/handlers_workspaces.go | 14 - .../cli/internal/transport/http/middleware.go | 33 +- .../cli/internal/transport/http/server.go | 22 +- 74 files changed, 1753 insertions(+), 3860 deletions(-) delete mode 100644 apps/dashboard/src/api/services.ts create mode 100644 apps/dashboard/src/features/global-variables/FolderTree.tsx delete mode 100644 apps/dashboard/src/features/global-variables/LocationPicker.tsx create mode 100644 apps/dashboard/src/features/infisical-session/SharedCredentialsNotice.tsx delete mode 100644 apps/dashboard/src/features/services/ServicePanel.tsx delete mode 100644 apps/dashboard/src/features/services/console.tsx delete mode 100644 apps/dashboard/src/features/workspace-settings/WorkspaceEnvironmentSettings.tsx delete mode 100644 apps/dashboard/src/features/workspace-settings/WorkspaceSettingsDialog.tsx delete mode 100644 apps/dashboard/src/pages/ProjectTemplates.tsx create mode 100644 packages/cli/internal/adapters/env/infisical/automatic_binding.go create mode 100644 packages/cli/internal/application/authentication/shared_credentials.go delete mode 100644 packages/cli/internal/application/execution/operation.go delete mode 100644 packages/cli/internal/application/workspace/tasks.go delete mode 100644 packages/cli/internal/modules/devservice/endpoints.go delete mode 100644 packages/cli/internal/modules/devservice/manager.go delete mode 100644 packages/cli/internal/modules/tasks/projection.go delete mode 100644 packages/cli/internal/platform/localurl/localurl.go delete mode 100644 packages/cli/internal/transport/cobra/run/service.go delete mode 100644 packages/cli/internal/transport/http/handlers_services.go diff --git a/README.md b/README.md index 48b719f8..d632c01a 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ One CLI manages variables in Infisical and injects them directly into commands. Run `one serve` from any directory for account settings, workspaces, and shared credentials. `one create` registers new workspaces automatically; running `one serve` inside an existing workspace or its subdirectories registers it and opens its page. Workspace binding changes use a reviewed, revision-checked TOML draft; project settings display derived configuration. Remote variable edits take effect immediately; lists omit values and reveal/copy fetch plaintext only on demand. -Choose shared credential storage with `one env bind --global`. Agents discover environments and folders through `one env --global` and `one env list --global`, then execute with `one exec --global --env dev --path /folder --keys KEY -- command`. One does not print injected values. Child logs preserve their original content and formatting. +Login automatically creates or reuses accessible `shared-credentials` in the current organization. Old account bindings are updated silently, and remote data is retained. Retry failed preparation with `one env bind --global`. Agents discover environments and folders through `one env --global` and `one env list --global`, then execute with `one exec --global --env dev --path /folder --keys KEY -- command`. One does not print injected values. Child logs preserve their original content and formatting. ## Project Map @@ -170,7 +170,7 @@ one serve # Manage this repository in the Dashboard `one run dev -p dashboard` starts the Vite UI; run `one run dev -p cli` in another terminal for its API, or use the combined `one run dev` task. Both the development API and `one serve` use this repository as their workspace. No Infisical binding is required to build, check, or start these projects. -Root tasks remain native mise commands, so local checks and first-time installation can use `mise run build`, `mise run check`, and `mise run install`. Project tasks come from package scripts and Taskfiles. Only the root `mise.toml` is used: project tasks are namespaced as `cli:build` or `dashboard:dev` and select their directory with `dir`. CLI tasks declare their embedded-resource prerequisites there. Run `one init mise` after changing the project task catalogue to refresh the tracked native tasks. Running or listing tasks does not regenerate configuration. Dashboard discovers access links from process output; dev commands are defined in mise.toml and executed by Process Compose. +Root tasks remain native mise commands, so local checks and first-time installation can use `mise run build`, `mise run check`, and `mise run install`. Project tasks come from package scripts and Taskfiles. Only the root `mise.toml` is used: project tasks are namespaced as `cli:build` or `dashboard:dev` and select their directory with `dir`. CLI tasks declare their embedded-resource prerequisites there. Run `one init mise` after changing the project task catalogue to refresh the tracked native tasks. Running or listing tasks does not regenerate configuration. Dev commands are defined in mise.toml and executed by Process Compose in the terminal. Run `mise run check` for Go vet, formatting, Dashboard lint, and documentation checks. diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index 94e46b9a..225f20b2 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -6,6 +6,7 @@ import { cn } from "@/lib/utils"; export const App: React.FC = () => { const workspaceMode = Boolean(useMatch("/workspace/:entryId")); + const globalMode = Boolean(useMatch("/global")); return (
@@ -18,7 +19,11 @@ export const App: React.FC = () => { )} >
diff --git a/apps/dashboard/src/api/services.ts b/apps/dashboard/src/api/services.ts deleted file mode 100644 index 3af25972..00000000 --- a/apps/dashboard/src/api/services.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { workspaceBasePath } from "@/api/workspaces"; -import http from "@/lib/http"; -import type { DevService } from "@/types/api"; - -export function servicePath(project: string, entryId?: string) { - return `${workspaceBasePath(entryId)}/projects/${encodeURIComponent(project)}/service`; -} -export function controlService( - project: string, - entryId: string | undefined, - action: "start" | "stop" | "restart", - environment: string, -) { - return http.post(`${servicePath(project, entryId)}/${action}`, { environment }); -} -export function watchService( - project: string, - entryId: string | undefined, - receive: (state: DevService) => void, - connection: (connected: boolean) => void, -) { - const source = new EventSource(`/api${servicePath(project, entryId)}/events`); - source.onmessage = (event) => { - receive(JSON.parse(event.data) as DevService); - connection(true); - }; - source.onerror = () => connection(false); - return () => source.close(); -} - -export function servicesKey(entryId?: string) { - return `${workspaceBasePath(entryId)}/services`; -} -export function getServices(entryId?: string) { - return http.get<{ services: DevService[] }>(servicesKey(entryId)); -} diff --git a/apps/dashboard/src/api/session.ts b/apps/dashboard/src/api/session.ts index 54ab0b36..3a0a5848 100644 --- a/apps/dashboard/src/api/session.ts +++ b/apps/dashboard/src/api/session.ts @@ -10,6 +10,7 @@ export interface SessionInfo { } export interface SessionState { session: SessionInfo; + sharedCredentials?: SharedCredentialsState | null; login?: { status: "waiting" | "complete" | "failed"; url: string }; error?: string; } @@ -30,6 +31,11 @@ export const createRemoteProject = (name: string) => http.post("/infisical/projects", { name }, { timeout: 120000 }); export const getProject = (id: string) => http.get(`/infisical/projects/${encodeURIComponent(id)}`); +// Metadata from another account or instance must not populate a binding form. +export const remoteProjectsKey = (account?: SessionInfo, projectId = "") => + account?.loggedIn + ? ["/infisical/projects", account.siteUrl, account.userId, account.organizationId, projectId] + : null; export interface GlobalLocation { siteUrl: string; userId: string; @@ -38,6 +44,11 @@ export interface GlobalLocation { projectName: string; defaultEnvironment: string; } +export interface SharedCredentialsState { + status: "preparing" | "ready" | "failed"; + location?: GlobalLocation; + error?: string; +} export interface GlobalListing { location: GlobalLocation; environment: string; @@ -45,14 +56,16 @@ export interface GlobalListing { folders: string[]; variables: { key: string; description?: string }[]; } -export const locationKey = "/global-env/location"; -export const getLocation = () => http.get<{ location: GlobalLocation | null }>(locationKey); -export const bindLocation = (projectId: string, environment: string) => - http.put<{ location: GlobalLocation }>(locationKey, { projectId, environment }); export const initializeGlobalLocation = () => - http.post<{ location: GlobalLocation }>(`${locationKey}/default`, {}, { timeout: 120000 }); + http.post<{ sharedCredentials: SharedCredentialsState }>( + "/global-env/location/default", + {}, + { timeout: 120000 }, + ); export const globalQuery = (environment: string, path: string) => `?${new URLSearchParams({ env: environment, path })}`; +export const globalListingKey = (location: GlobalLocation, query: string) => + `/global-env/secrets:${JSON.stringify([location.siteUrl, location.userId, location.organizationId, location.projectId])}${query}`; export const getGlobalListing = (query: string) => http.get(`/global-env/secrets${query}`); export const readGlobalSecret = (key: string, query: string) => diff --git a/apps/dashboard/src/components/AppMenu.tsx b/apps/dashboard/src/components/AppMenu.tsx index d366aefc..ab373dfe 100644 --- a/apps/dashboard/src/components/AppMenu.tsx +++ b/apps/dashboard/src/components/AppMenu.tsx @@ -1,4 +1,4 @@ -import { Boxes, ChevronDown, House, KeyRound, Settings2 } from "lucide-react"; +import { ChevronDown, House, KeyRound, Settings2 } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { @@ -66,15 +66,6 @@ export function AppMenu() { {t("global.title")} - - - - diff --git a/apps/dashboard/src/components/TopBar.tsx b/apps/dashboard/src/components/TopBar.tsx index 7518416c..d201906c 100644 --- a/apps/dashboard/src/components/TopBar.tsx +++ b/apps/dashboard/src/components/TopBar.tsx @@ -32,7 +32,6 @@ export const TopBar: React.FC = () => { const profileMatch = useMatch("/profile"); const settingsSectionMatch = useMatch("/settings/:domain/:backend"); const settingsMatch = useMatch("/settings"); - const templatesMatch = useMatch("/templates"); const globalMatch = useMatch("/global"); const workspaceMatch = useMatch("/workspace/:entryId"); @@ -52,10 +51,6 @@ export const TopBar: React.FC = () => { match={detailMatch.params} settingsRoute={Boolean(settingsSectionMatch)} /> - ) : templatesMatch ? ( - - {t("templateCatalog.title")} - ) : globalMatch ? ( {t("global.title")} diff --git a/apps/dashboard/src/components/ui/button.tsx b/apps/dashboard/src/components/ui/button.tsx index f9340d5e..b03b6343 100644 --- a/apps/dashboard/src/components/ui/button.tsx +++ b/apps/dashboard/src/components/ui/button.tsx @@ -23,6 +23,7 @@ const buttonVariants = cva( }, size: { navigation: "h-auto min-h-14 w-full gap-3 px-3 py-2", + "navigation-compact": "h-8 w-full min-w-0 max-w-full gap-2 px-2 py-1", default: "h-8 px-3 py-1 has-[>svg]:px-3", xs: "h-6 gap-1 px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", sm: "h-7 gap-1.5 px-3 text-xs has-[>svg]:px-2.5", diff --git a/apps/dashboard/src/features/global-variables/FolderTree.tsx b/apps/dashboard/src/features/global-variables/FolderTree.tsx new file mode 100644 index 00000000..f9a7032c --- /dev/null +++ b/apps/dashboard/src/features/global-variables/FolderTree.tsx @@ -0,0 +1,215 @@ +import { useState, type KeyboardEvent } from "react"; +import { useTranslation } from "react-i18next"; +import { ChevronDown, ChevronRight, Folder, FolderOpen } from "lucide-react"; +import useSWR from "swr"; +import { + getGlobalListing, + globalListingKey, + globalQuery, + message, + type GlobalLocation, +} from "@/api/session"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { ErrorNotice } from "@/components/ui/page-layout"; +import { Spinner } from "@/components/ui/spinner"; +import { cn } from "@/lib/utils"; + +type Props = { + location: GlobalLocation; + environment: string; + selectedPath: string; + onSelect(path: string): void; + disabled: boolean; +}; + +type BranchProps = Props & { + path: string; + depth: number; + branches: Record; + setExpanded(path: string, expanded: boolean): void; + focusedPath: string; + onFocus(path: string): void; +}; + +export function FolderTree(props: Props) { + const { t } = useTranslation(); + // Remember visited branches and expansion independently of the selected folder. + const [branches, setBranches] = useState>({ "/": true }); + const [focusedPath, setFocusedPath] = useState("/"); + return ( +
    + { + setBranches((current) => + Object.hasOwn(current, path) ? current : { ...current, [path]: false }, + ); + props.onSelect(path); + }} + path="/" + depth={0} + branches={branches} + setExpanded={(path, expanded) => + setBranches((current) => ({ ...current, [path]: expanded })) + } + focusedPath={focusedPath} + onFocus={setFocusedPath} + /> +
+ ); +} + +function FolderBranch(props: BranchProps) { + const { t } = useTranslation(); + const { + path, + depth, + branches, + setExpanded, + selectedPath, + onSelect, + disabled, + focusedPath, + onFocus, + } = props; + const expanded = branches[path] ?? false; + const query = globalQuery(props.environment, path); + const listing = useSWR( + Object.hasOwn(branches, path) || selectedPath === path + ? globalListingKey(props.location, query) + : null, + () => getGlobalListing(query), + ); + const folders = listing.data?.folders; + const expandable = folders === undefined || folders.length > 0; + const name = path === "/" ? t("global.rootFolder") : path.split("/").pop()!; + + function handleKeyDown(event: KeyboardEvent) { + if (event.target !== event.currentTarget || disabled) return; + const item = event.currentTarget; + const items = Array.from( + item.closest('[role="tree"]')?.querySelectorAll('[role="treeitem"]') ?? [], + ); + const index = items.indexOf(item); + switch (event.key) { + case "ArrowDown": + items[Math.min(index + 1, items.length - 1)]?.focus(); + break; + case "ArrowUp": + items[Math.max(index - 1, 0)]?.focus(); + break; + case "Home": + items[0]?.focus(); + break; + case "End": + items.at(-1)?.focus(); + break; + case "ArrowRight": + if (expandable && !expanded) setExpanded(path, true); + else item.querySelector('[role="group"] [role="treeitem"]')?.focus(); + break; + case "ArrowLeft": + if (expandable && expanded) setExpanded(path, false); + else item.parentElement?.closest('[role="treeitem"]')?.focus(); + break; + case "Enter": + case " ": + onSelect(path); + break; + default: + return; + } + event.preventDefault(); + event.stopPropagation(); + } + return ( +
  • { + if (event.target === event.currentTarget) onFocus(path); + }} + onKeyDown={handleKeyDown} + > +
    { + event.stopPropagation(); + if (disabled) return; + event.currentTarget.parentElement?.focus(); + onSelect(path); + }} + > + {expandable ? ( + + ) : ( +
    + {expanded && ( +
      + {listing.isLoading && ( +
    • + + {t("session.loading")} +
    • + )} + {listing.error && ( +
    • + void listing.mutate()}> + {t("secrets.retry")} + + } + > + {message(listing.error)} + +
    • + )} + {!listing.error && + folders?.map((child) => ( + + ))} + {!listing.error && folders?.length === 0 && path === "/" && ( +
    • + {t("global.noFolders")} +
    • + )} +
    + )} +
  • + ); +} diff --git a/apps/dashboard/src/features/global-variables/GlobalVariables.tsx b/apps/dashboard/src/features/global-variables/GlobalVariables.tsx index ae9104a7..557083f8 100644 --- a/apps/dashboard/src/features/global-variables/GlobalVariables.tsx +++ b/apps/dashboard/src/features/global-variables/GlobalVariables.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import useSWR from "swr"; +import useSWR, { useSWRConfig } from "swr"; import { Link } from "react-router-dom"; import { ArrowUp, @@ -16,7 +16,6 @@ import { Plus, RefreshCw, SearchX, - Settings2, ShieldCheck, Trash2, } from "lucide-react"; @@ -24,18 +23,19 @@ import { createGlobalFolder, deleteGlobalSecret, getGlobalListing, - getLocation, getProject, getSession, globalQuery, - locationKey, + globalListingKey, message, + remoteProjectsKey, readGlobalSecret, saveGlobalSecret, sessionKey, type GlobalLocation, } from "@/api/session"; -import { InfisicalBindingDialog } from "@/features/infisical-binding/InfisicalBindingDialog"; +import { FolderTree } from "./FolderTree"; +import { SharedCredentialsNotice } from "@/features/infisical-session/SharedCredentialsNotice"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Card } from "@/components/ui/card"; @@ -44,7 +44,6 @@ import { Field, FieldLabel } from "@/components/ui/field"; import { IconButton } from "@/components/ui/icon-button"; import { ErrorNotice, - PageHeader, SearchInput, SectionHeading, StatePanel, @@ -96,56 +95,39 @@ import { useToast } from "@/hooks/useToast"; export function GlobalVariables() { const { t } = useTranslation(); const session = useSWR(sessionKey, getSession, { refreshInterval: 2000 }); - const location = useSWR(locationKey, getLocation); - const [configure, setConfigure] = useState(false); - const current = location.data?.location; - const signedIn = session.data?.session.loggedIn; - const mismatched = + const account = session.data?.session; + const preparation = session.data?.sharedCredentials; + const current = preparation?.status === "ready" ? preparation.location : undefined; + const usable = current && - signedIn && - (current.userId !== session.data?.session.userId || - current.siteUrl !== session.data?.session.siteUrl || - (session.data?.session.organizationId && - current.organizationId !== session.data?.session.organizationId)); + account?.loggedIn && + current.userId === account.userId && + current.siteUrl === account.siteUrl && + current.organizationId === account.organizationId; return ( -
    - setConfigure(true)}> - - {t("binding.change")} - - ) : undefined - } - /> - {session.error || location.error ? ( +
    + {session.error ? ( { - void session.mutate(); - void location.mutate(); - }} - > + } > - {message(session.error || location.error)} + {message(session.error)} - ) : session.isLoading || location.isLoading ? ( -
    - - + ) : session.isLoading ? ( +
    + +
    - ) : !signedIn ? ( - + ) : !account?.loggedIn ? ( + + ) : usable ? ( + ) : ( - <> - {mismatched && {t("global.mismatch")}} - {!current || mismatched ? ( - - - - - - ) : ( - - )} - + - + )}
    ); @@ -191,17 +170,16 @@ export function GlobalVariables() { function VariableBrowser({ location }: { location: GlobalLocation }) { const { t } = useTranslation(); const toast = useToast(); + const { mutate } = useSWRConfig(); const [environment, setEnvironment] = useState(location.defaultEnvironment); const [path, setPath] = useState("/"); const [search, setSearch] = useState(""); - const detail = useSWR(`/infisical/projects/${location.projectId}`, () => - getProject(location.projectId), + const detail = useSWR( + remoteProjectsKey({ loggedIn: true, expired: false, ...location }, location.projectId), + () => getProject(location.projectId), ); const query = globalQuery(environment, path); - const listing = useSWR( - `/global-env/secrets:${location.userId}:${location.siteUrl}:${location.projectId}${query}`, - () => getGlobalListing(query), - ); + const listing = useSWR(globalListingKey(location, query), () => getGlobalListing(query)); const [revealed, setRevealed] = useState>({}); const epoch = useRef(0); const pending = useRef(false); @@ -260,7 +238,7 @@ function VariableBrowser({ location }: { location: GlobalLocation }) { if (copy) { await navigator.clipboard.writeText(value); toast.success(t("global.copied")); - } else setRevealed((v) => ({ ...v, [key]: value })); + } else setRevealed((v) => ({ ...v, [`${query}:${key}`]: value })); }); } function openEditor(key = "", existing = false) { @@ -286,12 +264,11 @@ function VariableBrowser({ location }: { location: GlobalLocation }) { const unavailable = busy || editing || listing.isLoading || Boolean(listing.error); return ( <> - -
    + +
    { - setProject(v); - setEnvironment(""); - }} - disabled={busy || projects.isLoading} - > - - - - - {projects.data?.map((p) => ( - - {p.name} - - ))} - - - -
    -
    -
    - - -
    -
    - {projects.data?.length === 0 ? ( -

    {t("global.noProjects")}

    - ) : null} - {error || projects.error || detail.error ? ( - { - void projects.mutate(); - void detail.mutate(); - }} - > - - {t("secrets.retry")} - - ) : undefined - } - > - {error || message(projects.error || detail.error)} - - ) : null} -
    - - {onCancel ? ( - - ) : null} -
    - - - { - if (!open) closeCreation(); - }} - > - - - {t("global.createProject")} - {t("global.createProjectHint")} - -
    { - e.preventDefault(); - if (!busy && name.trim()) void create(); - }} - > -
    - - setName(e.target.value)} - disabled={busy} - placeholder="shared-credentials" - /> -
    - {createError ? ( -

    - {createError} -

    - ) : null} - - - - -
    -
    -
    - { - setCreating(false); - setDiscard(false); - setName(""); - }} - /> - - ); -} diff --git a/apps/dashboard/src/features/infisical-binding/InfisicalBindingDialog.tsx b/apps/dashboard/src/features/infisical-binding/InfisicalBindingDialog.tsx index 4ad3daec..261dc84b 100644 --- a/apps/dashboard/src/features/infisical-binding/InfisicalBindingDialog.tsx +++ b/apps/dashboard/src/features/infisical-binding/InfisicalBindingDialog.tsx @@ -3,13 +3,14 @@ import { useTranslation } from "react-i18next"; import { Link, useSearchParams } from "react-router-dom"; import useSWR, { useSWRConfig } from "swr"; import { + createRemoteProject, getProject, getProjects, getSession, - initializeGlobalLocation, message, + remoteProjectsKey, sessionKey, - type GlobalLocation, + type RemoteProject, } from "@/api/session"; import { bindWorkspaceEnvironment, @@ -38,7 +39,6 @@ import { SelectValue, } from "@/components/ui/select"; import { Spinner } from "@/components/ui/spinner"; -import { LocationPicker } from "@/features/global-variables/LocationPicker"; import { ManifestSaveControl } from "@/features/manifest-draft/ManifestSaveControl"; import { manifestDraftKey, @@ -50,10 +50,8 @@ import type { HttpError } from "@/types/api"; type Props = { open: boolean; onOpenChange(open: boolean): void; - scope: "global" | "workspace"; workspaceEntryId?: string; environment?: string; - initial?: GlobalLocation; readOnly?: boolean; }; @@ -65,7 +63,7 @@ export function InfisicalBindingDialog(props: Props) { {props.open && ( )} @@ -73,68 +71,63 @@ export function InfisicalBindingDialog(props: Props) { ); } -function BindingForm({ - scope, - workspaceEntryId, - environment, - initial, - readOnly, - onOpenChange, -}: Props) { +function BindingForm({ workspaceEntryId, environment, readOnly, onOpenChange }: Props) { const { t } = useTranslation(); const toast = useToast(); const { mutate } = useSWRConfig(); const [, setSearchParams] = useSearchParams(); const session = useSWR(sessionKey, getSession); - const workspace = scope === "workspace"; - const settings = useSWR(workspace ? workspaceEnvironmentKey(workspaceEntryId) : null, () => + const account = session.data?.session; + const identity = `${account?.siteUrl}:${account?.userId}:${account?.organizationId}`; + const settings = useSWR(workspaceEnvironmentKey(workspaceEntryId), () => getWorkspaceEnvironment(workspaceEntryId), ); - const overview = useSWR(workspace ? overviewKeyFor(workspaceEntryId) : null, () => - getOverview(workspaceEntryId), - ); - const projects = useSWR( - session.data?.session.loggedIn ? "/infisical/projects" : null, - getProjects, - ); + const overview = useSWR(overviewKeyFor(workspaceEntryId), () => getOverview(workspaceEntryId)); + const projects = useSWR(remoteProjectsKey(account), getProjects); const [mode, setMode] = useState("automatic"); const [project, setProject] = useState(""); + const selectedProject = projects.data?.find((p) => p.id === project); const detail = useSWR( - workspace && mode === "existing" && project ? `/infisical/projects/${project}` : null, + mode === "existing" && selectedProject && !projects.error + ? remoteProjectsKey(account, project) + : null, () => getProject(project), ); + const selectedEnvironment = "dev"; const [busy, setBusy] = useState(false); const pending = useRef(false); const active = useRef(true); const [error, setError] = useState(""); const [conflict, setConflict] = useState(false); - const draft = useManifestDraftStore((s) => - workspace ? s.drafts[manifestDraftKey(workspaceEntryId)] : undefined, - ); - const initialId = workspace ? settings.data?.projectId : initial?.projectId; + const draft = useManifestDraftStore((s) => s.drafts[manifestDraftKey(workspaceEntryId)]); + const initialId = settings.data?.projectId; const initialized = useRef(false); useEffect(() => { - if (initialized.current || (workspace && !settings.data)) return; + if (initialized.current || !settings.data) return; initialized.current = true; if (initialId) { setMode("existing"); setProject(initialId); } - }, [workspace, settings.data, initialId]); + }, [settings.data, initialId]); useEffect(() => { active.current = true; return () => { active.current = false; }; }, []); - const account = session.data?.session; - const identity = `${account?.siteUrl}:${account?.userId}:${account?.organizationId}`; const currentIdentity = useRef(identity); currentIdentity.current = identity; - const currentName = - projects.data?.find((p) => p.id === initialId)?.name || initial?.projectName || initialId; - const targetName = workspace ? overview.data?.workspace?.name : "shared-credentials"; + const currentName = projects.data?.find((p) => p.id === initialId)?.name || initialId; + const [creationSuffix] = useState(() => crypto.randomUUID().slice(0, 4)); + const workspaceName = overview.data?.workspace?.name; + const targetName = + initialId && workspaceName + ? `${Array.from(workspaceName).slice(0, 59).join("")}-${creationSuffix}` + : workspaceName; const isExisting = mode === "existing"; + const unavailableBinding = + initialId && projects.data && !projects.error && !projects.data.some((p) => p.id === initialId); const canBind = !readOnly && !busy && @@ -142,10 +135,17 @@ function BindingForm({ !conflict && account?.loggedIn && account.organizationId && - (!workspace || (settings.data && overview.data && !settings.error && !overview.error)) && + settings.data && + overview.data && + !settings.error && + !overview.error && + (isExisting || !!targetName) && (!isExisting || - !workspace || - (detail.data?.environments.some((e) => e.slug === "dev") && !detail.error)); + (selectedProject && + !projects.error && + detail.data?.id === project && + detail.data.environments.some((e) => e.slug === selectedEnvironment) && + !detail.error)); async function finish(name: string) { if (!active.current) return; @@ -154,12 +154,9 @@ function BindingForm({ await Promise.allSettled([ mutate( (key) => - typeof key === "string" && - (workspace - ? key.startsWith(`${workspaceBasePath(workspaceEntryId)}/`) - : key === "/global-env/location"), + typeof key === "string" && key.startsWith(`${workspaceBasePath(workspaceEntryId)}/`), ), - mutate("/infisical/projects"), + projects.mutate(), ]); } async function bind() { @@ -168,31 +165,49 @@ function BindingForm({ setBusy(true); setError(""); const submittedIdentity = identity; + let createdProject: RemoteProject | undefined; try { - if (workspace) { - const result = await bindWorkspaceEnvironment(workspaceEntryId, { - revision: settings.data!.revision, - create: !isExisting, - ...(isExisting ? { projectId: project } : {}), + // The workspace initializer preserves existing bindings. Create explicitly + // before selecting the replacement, and retain it if saving fails. + if (!isExisting && initialId) { + createdProject = await createRemoteProject(targetName!); + if (!active.current || submittedIdentity !== currentIdentity.current) return; + await mutate(remoteProjectsKey(account, createdProject.id)!, createdProject, { + revalidate: false, }); - if (active.current && submittedIdentity === currentIdentity.current) { - if (environment && !result.environments.includes(environment)) { - setSearchParams((current) => { - const next = new URLSearchParams(current); - next.set("env", "dev"); - return next; - }); - } - await finish(result.project_name || result.project_id); + const created = createdProject; + await projects.mutate( + (current) => [...(current ?? []).filter((p) => p.id !== created.id), created], + { revalidate: false }, + ); + } + const result = await bindWorkspaceEnvironment(workspaceEntryId, { + revision: settings.data!.revision, + create: !isExisting && !createdProject, + ...(createdProject + ? { projectId: createdProject.id } + : isExisting + ? { projectId: project } + : {}), + }); + if (active.current && submittedIdentity === currentIdentity.current) { + if (environment && !result.environments.includes(environment)) { + setSearchParams((current) => { + const next = new URLSearchParams(current); + next.set("env", "dev"); + return next; + }); } - } else { - const result = await initializeGlobalLocation(); - if (submittedIdentity === currentIdentity.current) - await finish(result.location.projectName); + await finish(result.project_name || result.project_id); } } catch (cause) { if (!active.current || submittedIdentity !== currentIdentity.current) return; const failure = cause as HttpError; + if (createdProject) { + setMode("existing"); + setProject(createdProject.id); + void projects.mutate().catch(() => undefined); + } if ( failure.context?.partial_state === "project_created_binding_unsaved" && typeof failure.context.project_id === "string" @@ -202,7 +217,17 @@ function BindingForm({ void projects.mutate().catch(() => undefined); } setConflict(failure.code === "SERVE_MANIFEST_CONFLICT"); - setError(failure.code === "SERVE_MANIFEST_CONFLICT" ? t("binding.conflict") : message(cause)); + const reason = + failure.code === "SERVE_MANIFEST_CONFLICT" ? t("binding.conflict") : message(cause); + setError( + createdProject + ? t("binding.createdUnsaved", { + name: createdProject.name, + id: createdProject.id, + reason, + }) + : reason, + ); } finally { pending.current = false; if (active.current) setBusy(false); @@ -219,10 +244,8 @@ function BindingForm({ }} > - {t(workspace ? "binding.workspaceTitle" : "binding.globalTitle")} - - {t(workspace ? "binding.workspaceHint" : "binding.globalHint")} - + {t(initialId ? "binding.change" : "binding.workspaceTitle")} + {t("binding.workspaceHint")} {session.error ? ( {message(session.error)} @@ -260,7 +283,7 @@ function BindingForm({
    - {isExisting && !workspace ? ( - { - await finish(location?.projectName || t("global.title")); - }} - onCancel={() => onOpenChange(false)} - onBusyChange={setBusy} - /> - ) : ( - <> - {isExisting ? ( -
    - - - {detail.data && !detail.data.environments.some((e) => e.slug === "dev") && ( - {t("binding.devRequired")} - )} - {projects.data?.length === 0 && ( -

    {t("binding.noProjects")}

    - )} -
    - ) : null} -
    -

    - {t("binding.target", { - name: isExisting - ? detail.data?.name || project - : targetName || t("session.loading"), - })} -

    -

    {t("binding.defaultEnvironment")}

    -

    - {t(workspace ? "binding.workspaceStorage" : "binding.globalStorage")} + {isExisting ? ( +

    + + + {unavailableBinding && (!project || project === initialId) && ( + {t("binding.unavailable")} + )} + {detail.isLoading && ( +

    + {t("session.loading")}

    -
    - {settings.error || - overview.error || - (isExisting && (projects.error || detail.error)) ? ( - - {message(settings.error || overview.error || projects.error || detail.error)} - - ) : null} - {draft && ( -
    -

    {t("binding.pendingDraft")}

    -
    - {workspaceEntryId && } - -
    -
    )} - {error && ( - { - await Promise.allSettled([settings.mutate(), overview.mutate()]); - setConflict(false); - setError(""); - }} - > - {t("secrets.retry")} - - ) : undefined - } - > - {error} - + {detail.data && !detail.data.environments.some((e) => e.slug === "dev") && ( + {t("binding.devRequired")} )} - - -
    + ) : null} +
    +

    + {t("binding.target", { + name: isExisting + ? detail.data?.name || selectedProject?.name || t("global.selectProject") + : targetName || t("session.loading"), + })} +

    + {initialId && (!isExisting || project !== initialId) && ( +

    {t("binding.replaceHint")}

    + )} +

    {t("binding.defaultEnvironment")}

    +

    {t("binding.workspaceStorage")}

    +
    + {settings.error || overview.error || (isExisting && (projects.error || detail.error)) ? ( + + {message(settings.error || overview.error || projects.error || detail.error)} + + ) : null} + {draft && ( +
    +

    {t("binding.pendingDraft")}

    +
    + {workspaceEntryId && } + - - +
    +
    )} + {error && ( + { + await Promise.allSettled([settings.mutate(), overview.mutate()]); + setConflict(false); + setError(""); + }} + > + {t("secrets.retry")} + + ) : undefined + } + > + {error} + + )} + + + + )} diff --git a/apps/dashboard/src/features/infisical-session/AccountSettings.tsx b/apps/dashboard/src/features/infisical-session/AccountSettings.tsx index a5821713..1a2725cb 100644 --- a/apps/dashboard/src/features/infisical-session/AccountSettings.tsx +++ b/apps/dashboard/src/features/infisical-session/AccountSettings.tsx @@ -1,6 +1,5 @@ import { ExternalLink, - KeyRound, Languages, LogIn, LogOut, @@ -10,7 +9,7 @@ import { Settings2, ShieldCheck, } from "lucide-react"; -import { Link } from "react-router-dom"; +import { SharedCredentialsNotice } from "./SharedCredentialsNotice"; import { Badge } from "@/components/ui/badge"; import { ErrorNotice, PageHeader, SectionHeading } from "@/components/ui/page-layout"; import { Skeleton } from "@/components/ui/skeleton"; @@ -109,23 +108,35 @@ export function AccountSettings() {

    -
    - + +
    + } + > +

    {t("global.preparationFailed")}

    +

    {error || state.error}

    + + ); +} diff --git a/apps/dashboard/src/features/project-settings/ProjectInspector.tsx b/apps/dashboard/src/features/project-settings/ProjectInspector.tsx index 043d37f8..73670960 100644 --- a/apps/dashboard/src/features/project-settings/ProjectInspector.tsx +++ b/apps/dashboard/src/features/project-settings/ProjectInspector.tsx @@ -4,21 +4,26 @@ import { } from "@/features/manifest-draft/manifest-draft-store"; import { Plus, - Terminal, Server, - Code2, - FileKey2, - Library, - Search, - LayoutGrid, + AppWindow, + Package, LockKeyhole, + KeyRound, + ChevronRight, + type LucideIcon, } from "lucide-react"; +import { Collapsible } from "radix-ui"; import type React from "react"; import { useEffect, useId, useState } from "react"; import { useTranslation } from "react-i18next"; +import { useSearchParams } from "react-router-dom"; import useSWR from "swr"; -import { getServices, servicesKey } from "@/api/services"; -import { getProjectSettings, projectSettingsKey } from "@/api/workspace"; +import { + getProjectSettings, + getWorkspaceEnvironment, + projectSettingsKey, + workspaceEnvironmentKey, +} from "@/api/workspace"; import { CreateProjectDialog } from "@/features/project-creation/CreateProjectDialog"; import { ManifestSaveControl } from "@/features/manifest-draft/ManifestSaveControl"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; @@ -34,7 +39,6 @@ import { } from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"; import { Select, SelectContent, @@ -43,55 +47,61 @@ import { SelectValue, } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { EnvironmentSelector } from "@/features/environment-context/EnvironmentSelector"; import { useEnvironmentDirtyStore } from "@/features/environment-context/environment-dirty-store"; import { EnvironmentForm } from "@/features/project-settings/forms/EnvironmentForm"; -import { ServicePanel } from "@/features/services/ServicePanel"; import { GeneralForm } from "@/features/project-settings/forms/GeneralForm"; -import { WorkspaceSettingsDialog } from "@/features/workspace-settings/WorkspaceSettingsDialog"; +import { InfisicalBindingDialog } from "@/features/infisical-binding/InfisicalBindingDialog"; import { cn } from "@/lib/utils"; -import type { OverviewProject, ProjectSettingsResponse } from "@/types/api"; - -type ProjectInspectorTab = "overview" | "environment" | "runtime"; +import type { OverviewProject } from "@/types/api"; interface ProjectInspectorProps { environments?: string[]; projects: OverviewProject[]; - currentBackend?: string; environment: string; workspaceEntryId?: string; readOnly?: boolean; } -const TAB_ITEMS: ReadonlyArray<{ - id: ProjectInspectorTab; - icon: React.ComponentType<{ className?: string }>; -}> = [ - { id: "overview", icon: LayoutGrid }, - { id: "runtime", icon: Terminal }, - { id: "environment", icon: FileKey2 }, -]; +const PROJECT_GROUPS = [ + { kind: "app", directory: "apps" }, + { kind: "package", directory: "packages" }, + { kind: "service", directory: "services" }, +] as const; export const ProjectInspector: React.FC = ({ environments, projects, - currentBackend, environment, workspaceEntryId, readOnly, }) => { const { t } = useTranslation(); const dirtyOwner = useId(); - const services = useSWR(servicesKey(workspaceEntryId), () => getServices(workspaceEntryId), { - refreshInterval: 2000, - }); - const [query, setQuery] = useState(""); + const [collapsedGroups, setCollapsedGroups] = useState< + Partial> + >({}); const [createOpen, setCreateOpen] = useState(false); + const [bindingOpen, setBindingOpen] = useState(false); + const binding = useSWR(workspaceEnvironmentKey(workspaceEntryId), () => + getWorkspaceEnvironment(workspaceEntryId), + ); const canCreate = !!workspaceEntryId && !readOnly; const [dirty, setDirty] = useState(false); const [pendingAction, setPendingAction] = useState<(() => void) | null>(null); - const [selectedName, setSelectedName] = useState(projects[0]?.name ?? ""); + const [searchParams, setSearchParams] = useSearchParams(); + const selectedName = searchParams.get("project") ?? projects[0]?.name ?? ""; + function setSelectedName(name: string) { + setSearchParams( + (current) => { + const next = new URLSearchParams(current); + next.set("project", name); + next.delete("tab"); + return next; + }, + { replace: true }, + ); + } const setEnvironmentDirty = useEnvironmentDirtyStore((state) => state.setDirty); const clearEnvironmentDirty = useEnvironmentDirtyStore((state) => state.clearOwner); const selectedProject = projects.find((project) => project.name === selectedName) ?? projects[0]; @@ -116,11 +126,6 @@ export const ProjectInspector: React.FC = ({ setPendingAction(() => action); } - const filteredProjects = projects.filter((project) => - `${project.name} ${project.relativeDir} ${project.domains?.env ?? currentBackend ?? ""}` - .toLocaleLowerCase() - .includes(query.trim().toLocaleLowerCase()), - ); function selectProject(name: string) { if (name === selectedProject?.name) return; requestDiscard(() => { @@ -136,88 +141,81 @@ export const ProjectInspector: React.FC = ({ aria-label={t("projectInspector.workspaceTitle")} className="flex h-full min-h-0 flex-col overflow-hidden bg-background ud-md:grid ud-md:grid-cols-[208px_minmax(0,1fr)] ud-lg:grid-cols-[240px_minmax(0,1fr)]" > -