test(#1970): consolidate 61 install-suite regression tests into function suites
Fold 61 issue-named install/codex/runtime regression files into the canonical test file that owns each subject-under-test, preserving every assertion and its origin issue number as provenance (block-scoped describe wrappers, zero assertion loss — 652 subtests conserved 1:1). Routes: - codex-config.test.cjs +19 (codex config/toml/hooks/adapter/skill surface) - install.test.cjs +18 (node-runner norm, manifest, arg parse, finishInstall) - install-runtime-artifacts +13 (per-runtime conversion + emission) - install-minimal-hooks +6 (hook-event dialects + guards) - path-replacement +2 (opencode absolute pathPrefix) - install-write-confinement +2 (pristine dir writes) - install-regressions +1 (user-artifact preservation) Removes 61 tests/ files → 61 fewer CI processes. Regenerates the regression-name allowlist (271→222), ratchets the file-count allowlist (config 10→9, install 12→9), and makes 13 relocated allow-test-rule exemptions issue-ref-compliant (ADR-456; prunes 13 stale allowlist ids). Repoints ADR-0009's moved-test list. lint:ci green. Part of epic #1969. Closes #1970. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -52,11 +52,11 @@ These call sites should migrate behind the Shell Command Projection Module:
|
||||
|
||||
### Tests expected to move with the seam
|
||||
|
||||
- `tests/bug-2979-hook-absolute-node.test.cjs`
|
||||
- `tests/install.test.cjs` (folds former `bug-2979-hook-absolute-node`, consolidation epic #1969)
|
||||
- `tests/sh-hook-paths.test.cjs`
|
||||
- `tests/bug-3011-sdk-path-diagnostic.test.cjs`
|
||||
- `tests/bug-3017-codex-hook-absolute-node.test.cjs`
|
||||
- `tests/bug-2376-opencode-windows-home-path.test.cjs`
|
||||
- `tests/codex-config.test.cjs` (folds former `bug-3017-codex-hook-absolute-node`, consolidation epic #1969)
|
||||
- `tests/path-replacement.test.cjs` (folds former `bug-2376-opencode-windows-home-path`, consolidation epic #1969)
|
||||
- `tests/bug-3020-install-shell-path-probe.test.cjs`
|
||||
- `tests/bug-3359-stale-gsd-sdk-path-version.test.cjs`
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
"tests/bug-17-askuserquestion-option-cap.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-170-workflow-fallback-install-hint.test.cjs :: workflow markdown is shipped product text; this test validates fallback hint literals across all workflow files",
|
||||
"tests/bug-1891-file-resolution.test.cjs :: structural-implementation-guard",
|
||||
"tests/bug-1924-preserve-user-artifacts.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-1967-cache-invalidation.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-211-launcher-home-fallback.test.cjs :: structural/behavioral regression for the ~/.claude fallback arm in",
|
||||
"tests/bug-2136-sh-hook-version.test.cjs :: structural-regression-guard",
|
||||
@@ -44,13 +43,10 @@
|
||||
"tests/bug-2559-stale-search-year.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-2661-roadmap-sync-parallel.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-2686-review-fix-worktree.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-2698-crlf-install.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-2770-annotate-deps-int-coerce.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-2772-gitmodules-path-intersection.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-2784-update-cache-clear-path.test.cjs :: structural-regression-guard",
|
||||
"tests/bug-279-codex-agent-mapping.test.cjs :: source-text-is-the-product [adapter header contract in bin/install.js]",
|
||||
"tests/bug-2808-skill-hyphen-name.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-2831-opencode-home-path-prefix.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-2839-review-fix-transactional-cleanup.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-2948-spike-wrap-up-dispatch.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-2949-sketch-wrap-up-dispatch.test.cjs :: source-text-is-the-product",
|
||||
@@ -85,7 +81,6 @@
|
||||
"tests/bug-3521-quick-cleanup-cwd-pin.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-3523-cjs-loadconfig-branching-strategy-warning.test.cjs :: validates runtime CLI stdout/stderr warning behavior, not source grep",
|
||||
"tests/bug-3542-executor-git-stash-prohibition.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-3582-codex-skills-materialized.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-3605-stale-research-insert-phase-agent-refs.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-3628-bundled-hook-classifier-whitelist.test.cjs :: architectural-invariant",
|
||||
"tests/bug-3657-verify-reapply-patches-pristine-drift.test.cjs :: source-text-is-the-product.",
|
||||
@@ -108,7 +103,6 @@
|
||||
"tests/bug-444-resolver-local-claude-install.test.cjs :: structural/behavioral regression for the repo-local .claude/ install",
|
||||
"tests/bug-474-clock-seam-date-determinism.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-503-update-agent-antigravity-detection.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-570-codex-leak-scanner.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-571-doc-writer-fix-mode-edit-only.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-619-codebase-drift-gate-shim.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-621-plan-phase-gap-analysis-gsd-run.test.cjs :: source-text-is-the-product",
|
||||
@@ -117,8 +111,6 @@
|
||||
"tests/bug-637-workflow-no-hardcoded-home-tool.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-685-windowshide-spawn.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-687-agy-timeout.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-704-codex-launcher-path-corruption.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-851-codex-quick-adapter-agent-type-fallback.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-891-non-claude-runtime-home-fallback.test.cjs :: structural/behavioral regression for non-Claude runtime-home",
|
||||
"tests/bug-924-claude-flat-skill-layout.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-936-no-nested-spawner-wrap.test.cjs :: source-text-is-the-product",
|
||||
@@ -126,7 +118,6 @@
|
||||
"tests/bug-950-quick-summary-status-complete.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-967-verify-key-links-strict-paths.test.cjs :: <runtime-contract-is-the-product> the plan-md.md reference",
|
||||
"tests/bug-967-verify-key-links-strict-paths.test.cjs :: <runtime-contract-is-the-product> the plan-md.md reference example IS the documented authoring surface for key_links; asserting it uses a file path (not an endpoint) directly tests the documented contract.",
|
||||
"tests/bug-983-trae-windsurf-claude-path-leak.test.cjs :: source-text-is-the-product",
|
||||
"tests/bug-patterns-reference.test.cjs :: source-text-is-the-product",
|
||||
"tests/chain-flag-plan-phase.test.cjs :: source-text-is-the-product",
|
||||
"tests/changeset-cli.test.cjs :: reads a product workflow .md file (not CJS source) to verify",
|
||||
@@ -180,14 +171,11 @@
|
||||
"tests/enh-3209-plan-phase-ingest-adr.test.cjs :: source-text-is-the-product",
|
||||
"tests/enh-48-cwd-drift-guard-e2e.test.cjs :: integration-test-input",
|
||||
"tests/enh-72-business-context.test.cjs :: source-text-is-the-product",
|
||||
"tests/enh-769-context-fork-effort.install.test.cjs :: integration-test-input",
|
||||
"tests/enh-770-claude-hook-events.test.cjs :: runtime-contract-is-the-product — hooks.json IS the",
|
||||
"tests/enh-770-claude-hook-events.test.cjs :: runtime-contract-is-the-product — the hookEventName is",
|
||||
"tests/enh-770-claude-hook-events.test.cjs :: runtime-contract-is-the-product — the stamp template token",
|
||||
"tests/enh-770-claude-hook-events.test.cjs :: runtime-contract-is-the-product — the stdin-read and",
|
||||
"tests/enh-773-codex-exec-automation-flags.test.cjs :: source-text-is-the-product",
|
||||
"tests/enh-778-cross-runtime-command-enrichment.test.cjs :: source-text-is-the-product",
|
||||
"tests/enh-789-codebuddy-commands.test.cjs :: source-text-is-the-product",
|
||||
"tests/eslint-rules.test.cjs :: <source-grep reason> must still error",
|
||||
"tests/eslint-rules.test.cjs :: pending migration",
|
||||
"tests/eslint-rules.test.cjs :: source-text-is-the-product",
|
||||
@@ -207,7 +195,6 @@
|
||||
"tests/feat-3210-fallow-integration.test.cjs :: source-text-is-the-product",
|
||||
"tests/feat-3210-fallow-integration.test.cjs :: source-text-is-the-product — code-review.md IS the workflow the orchestrator",
|
||||
"tests/feat-3309-human-verify-mode.test.cjs :: source-text-is-the-product",
|
||||
"tests/feat-443-effort-install-wiring.install.test.cjs :: integration-test-input",
|
||||
"tests/feat-488-effort-sync.test.cjs :: structural-regression-guard — readFileSync asserts on installed agent .md files (the product under mutation) to verify dry-run safety and apply correctness; stderr.includes guards the CLI argument-rejection contract.",
|
||||
"tests/fix-3722-execute-phase-human-needed-checkpoint.test.cjs :: source-text-is-the-product",
|
||||
"tests/forensics.test.cjs :: source-text-is-the-product",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
[
|
||||
"bug-10-semver-policy-consolidation.test.cjs",
|
||||
"bug-130-finishinstall-opencode-testmode.test.cjs",
|
||||
"bug-131-release-tarball-smoke-explicit-home.test.cjs",
|
||||
"bug-1367-claude-local-flat-command-layout.test.cjs",
|
||||
"bug-14-progress-auto-flag-dropped.test.cjs",
|
||||
@@ -8,11 +7,8 @@
|
||||
"bug-1695-state-patch-clobbers-phase-name.test.cjs",
|
||||
"bug-17-askuserquestion-option-cap.test.cjs",
|
||||
"bug-170-workflow-fallback-install-hint.test.cjs",
|
||||
"bug-1736-local-install-commands.test.cjs",
|
||||
"bug-1754-js-hook-guard.test.cjs",
|
||||
"bug-1760-state-prune-noop-template-field.test.cjs",
|
||||
"bug-1761-state-sync-wrong-progress.test.cjs",
|
||||
"bug-1817-sh-hook-guard.test.cjs",
|
||||
"bug-1818-unknown-flags.test.cjs",
|
||||
"bug-1826-phases-clear-confirm.test.cjs",
|
||||
"bug-1829-inherit-model-profile.test.cjs",
|
||||
@@ -20,8 +16,6 @@
|
||||
"bug-1891-file-resolution.test.cjs",
|
||||
"bug-190-bridge-collapse.test.cjs",
|
||||
"bug-1906-hook-relative-paths.test.cjs",
|
||||
"bug-1908-uninstall-manifest.test.cjs",
|
||||
"bug-1924-preserve-user-artifacts.test.cjs",
|
||||
"bug-1967-cache-invalidation.test.cjs",
|
||||
"bug-1974-context-exhaustion-record.test.cjs",
|
||||
"bug-2002-offer-next-context.test.cjs",
|
||||
@@ -33,19 +27,15 @@
|
||||
"bug-214-writer-agents-write-truncation-contract.test.cjs",
|
||||
"bug-222-research-synthesizer-write-contract.test.cjs",
|
||||
"bug-224-pick-stdout-capture.test.cjs",
|
||||
"bug-2248-local-install-statusline.test.cjs",
|
||||
"bug-2256-model-overrides-transport.test.cjs",
|
||||
"bug-2268-parallel-discuss.test.cjs",
|
||||
"bug-2344-read-guard-claudecode-env.test.cjs",
|
||||
"bug-2346-agent-read-loop-guards.test.cjs",
|
||||
"bug-2351-intel-kilo-layout.test.cjs",
|
||||
"bug-2376-opencode-windows-home-path.test.cjs",
|
||||
"bug-2384-post-merge-deletion-audit.test.cjs",
|
||||
"bug-2388-plan-phase-no-branch-rename.test.cjs",
|
||||
"bug-2396-makefile-test-priority.test.cjs",
|
||||
"bug-2399-commit-docs-plan-phase.test.cjs",
|
||||
"bug-2410-stream-checkpoint-heartbeats.test.cjs",
|
||||
"bug-2418-antigravity-bare-path.test.cjs",
|
||||
"bug-2419-project-researcher-agent.test.cjs",
|
||||
"bug-2421-planner-grep-gate-hygiene.test.cjs",
|
||||
"bug-2424-reapply-patches-baseline-detection.test.cjs",
|
||||
@@ -62,7 +52,6 @@
|
||||
"bug-2523-quick-deferred-items.test.cjs",
|
||||
"bug-2530-valid-config-keys.test.cjs",
|
||||
"bug-2543-gsd-slash-namespace.test.cjs",
|
||||
"bug-2545-copilot-unreplaced-paths.test.cjs",
|
||||
"bug-2549-2550-2552-discuss-phase-context.test.cjs",
|
||||
"bug-2554-decimal-phase-filter.test.cjs",
|
||||
"bug-2557-gemini-local-hook-paths.test.cjs",
|
||||
@@ -72,24 +61,17 @@
|
||||
"bug-261-worktree-force-add-guard.test.cjs",
|
||||
"bug-2630-state-frontmatter-milestone-switch.test.cjs",
|
||||
"bug-2638-sub-repos-canonical-location.test.cjs",
|
||||
"bug-2643-skill-frontmatter-name.test.cjs",
|
||||
"bug-2659-audit-open-crash.test.cjs",
|
||||
"bug-2660-one-liner-extraction.test.cjs",
|
||||
"bug-2661-roadmap-sync-parallel.test.cjs",
|
||||
"bug-2686-review-fix-worktree.test.cjs",
|
||||
"bug-2698-crlf-install.test.cjs",
|
||||
"bug-2760-codex-install-defensive.test.cjs",
|
||||
"bug-2769-requirements-header-variants.test.cjs",
|
||||
"bug-2770-annotate-deps-int-coerce.test.cjs",
|
||||
"bug-2771-user-profile-manifest.test.cjs",
|
||||
"bug-2772-gitmodules-path-intersection.test.cjs",
|
||||
"bug-2784-update-cache-clear-path.test.cjs",
|
||||
"bug-279-codex-agent-mapping.test.cjs",
|
||||
"bug-2794-opencode-model-profile-overrides.test.cjs",
|
||||
"bug-2798-context-window-config-key.test.cjs",
|
||||
"bug-2801-ingest-docs-handler.test.cjs",
|
||||
"bug-2808-skill-hyphen-name.test.cjs",
|
||||
"bug-2831-opencode-home-path-prefix.test.cjs",
|
||||
"bug-2836-audit-open-summary-uat-drift.test.cjs",
|
||||
"bug-2838-summary-rescue-gitignored-planning.test.cjs",
|
||||
"bug-2839-review-fix-transactional-cleanup.test.cjs",
|
||||
@@ -105,20 +87,14 @@
|
||||
"bug-2949-sketch-wrap-up-dispatch.test.cjs",
|
||||
"bug-2950-stale-command-refs.test.cjs",
|
||||
"bug-2954-help-md-slash-command-stubs.test.cjs",
|
||||
"bug-2957-claude-global-postinstall-message.test.cjs",
|
||||
"bug-2969-verify-reapply-patches.test.cjs",
|
||||
"bug-2973-profile-user-skills-path.test.cjs",
|
||||
"bug-2979-hook-absolute-node.test.cjs",
|
||||
"bug-2986-config-schema-mutation-killers.test.cjs",
|
||||
"bug-2990-code-fixer-worktree-branch.test.cjs",
|
||||
"bug-2992-check-latest-version.test.cjs",
|
||||
"bug-2994-verify-reapply-patches-installed-path.test.cjs",
|
||||
"bug-2995-post-install-script-paths.test.cjs",
|
||||
"bug-2998-pristine-dir-populated.test.cjs",
|
||||
"bug-3017-codex-hook-absolute-node.test.cjs",
|
||||
"bug-3018-codex-discuss-fallback.test.cjs",
|
||||
"bug-3019-help-passthrough.test.cjs",
|
||||
"bug-3037-gemini-duplicate-commands.test.cjs",
|
||||
"bug-3050-update-backup-eacces-nonfatal.test.cjs",
|
||||
"bug-3054-stale-gsd-next-references.test.cjs",
|
||||
"bug-3072-optional-sketch-findings-guard.test.cjs",
|
||||
@@ -138,7 +114,6 @@
|
||||
"bug-3156-plan-phase-opencode-dispatch.test.cjs",
|
||||
"bug-3163-codex-agents-md.test.cjs",
|
||||
"bug-3168-task-to-agent-rename.test.cjs",
|
||||
"bug-3181-node-cellar-path.test.cjs",
|
||||
"bug-3195-quick-resurrection-guard.test.cjs",
|
||||
"bug-3197-gsd-tools-config-whitelist.test.cjs",
|
||||
"bug-321-config-defaults-clone-strategy.test.cjs",
|
||||
@@ -147,28 +122,19 @@
|
||||
"bug-3236-capture-seed-one-shot.test.cjs",
|
||||
"bug-3242-state-update-progress-trample.test.cjs",
|
||||
"bug-3243-dotted-command-form.test.cjs",
|
||||
"bug-3245-codex-toml-floats.test.cjs",
|
||||
"bug-3257-nested-plans-undercount.test.cjs",
|
||||
"bug-3258-no-stale-gsd-intel-references.test.cjs",
|
||||
"bug-3275-fmstr-non-string-scalars.test.cjs",
|
||||
"bug-3285-codex-hooks-state-allowed.test.cjs",
|
||||
"bug-3286-state-write-routing.test.cjs",
|
||||
"bug-3288-model-catalog-install-path.test.cjs",
|
||||
"bug-3290-intel-updater-layout-block.test.cjs",
|
||||
"bug-33-settings-model-profile-adaptive.test.cjs",
|
||||
"bug-3320-planner-deep-work-rules.test.cjs",
|
||||
"bug-3321-verifier-runs-probes.test.cjs",
|
||||
"bug-3346-codex-aot-toml-key.test.cjs",
|
||||
"bug-3357-codex-legacy-hooks-json-migration.test.cjs",
|
||||
"bug-3360-codex-execute-phase-worktrees.test.cjs",
|
||||
"bug-338-local-install-settings-local-json.test.cjs",
|
||||
"bug-3381-verify-work-workstream.test.cjs",
|
||||
"bug-3384-secondary-defects.test.cjs",
|
||||
"bug-3407-pristine-stale-content.test.cjs",
|
||||
"bug-3413-shell-command-projection.test.cjs",
|
||||
"bug-3418-progress-flag-routing.test.cjs",
|
||||
"bug-3426-codex-windows-hooks.test.cjs",
|
||||
"bug-3427-3433-codex-install-shape.test.cjs",
|
||||
"bug-3430-planner-phase-contract.test.cjs",
|
||||
"bug-3431-debug-command-yaml.test.cjs",
|
||||
"bug-3441-path-action-projection.test.cjs",
|
||||
@@ -185,10 +151,6 @@
|
||||
"bug-3537-padded-id-against-unpadded-roadmap.test.cjs",
|
||||
"bug-3541-installer-migration-prompt-user-resolution.test.cjs",
|
||||
"bug-3542-executor-git-stash-prohibition.test.cjs",
|
||||
"bug-3562-codex-install-skill-surface.test.cjs",
|
||||
"bug-3566-codex-hooks-feature-canonical-key.test.cjs",
|
||||
"bug-3571-configuration-manifest-install-path.test.cjs",
|
||||
"bug-3582-codex-skills-materialized.test.cjs",
|
||||
"bug-3584-runtime-slash-emitters.test.cjs",
|
||||
"bug-3584-runtime-slash-formatter.test.cjs",
|
||||
"bug-3588-npm-audit-clean.test.cjs",
|
||||
@@ -214,17 +176,13 @@
|
||||
"bug-3727-code-review-fix-flag-dispatch.test.cjs",
|
||||
"bug-3735-profiles-core-includes-surface.test.cjs",
|
||||
"bug-3739-gap-checker-padded-prefix-context.test.cjs",
|
||||
"bug-376-claude-js-hook-gsd-rewriter.test.cjs",
|
||||
"bug-378-update-check-scoped-name.test.cjs",
|
||||
"bug-3784-gsd-settings-model-profile-ui-omits-adaptive.test.cjs",
|
||||
"bug-3805-fast-md-log-to-state-schema.test.cjs",
|
||||
"bug-3808-codex-adapter-text-mode-fallback.test.cjs",
|
||||
"bug-3810-no-gsd-sdk-runtime-refs.test.cjs",
|
||||
"bug-384-agents-runtime-aware.test.cjs",
|
||||
"bug-397-state-preserve-executor-authored.test.cjs",
|
||||
"bug-410-install-defaults-test-mode-guard.test.cjs",
|
||||
"bug-416-archive-dir-null.test.cjs",
|
||||
"bug-442-config-dir-equals-in-path.test.cjs",
|
||||
"bug-444-resolver-local-claude-install.test.cjs",
|
||||
"bug-447-gap-analysis-phase-req-ids.test.cjs",
|
||||
"bug-474-clock-seam-date-determinism.test.cjs",
|
||||
@@ -232,10 +190,8 @@
|
||||
"bug-500-planned-phase-progress-corruption.test.cjs",
|
||||
"bug-501-flat-phase-details-milestone-leak.test.cjs",
|
||||
"bug-503-update-agent-antigravity-detection.test.cjs",
|
||||
"bug-505-remove-dead-sdk-verification.test.cjs",
|
||||
"bug-549-total-phases-overcounts-with-phase-section-heading.test.cjs",
|
||||
"bug-557-details-summary-milestone-strip.test.cjs",
|
||||
"bug-570-codex-leak-scanner.test.cjs",
|
||||
"bug-571-doc-writer-fix-mode-edit-only.test.cjs",
|
||||
"bug-580-local-sh-hook-bash-wrapper.test.cjs",
|
||||
"bug-619-codebase-drift-gate-shim.test.cjs",
|
||||
@@ -247,11 +203,8 @@
|
||||
"bug-663-redos-roadmap-phase-parsing.test.cjs",
|
||||
"bug-685-windowshide-spawn.test.cjs",
|
||||
"bug-687-agy-timeout.test.cjs",
|
||||
"bug-704-codex-launcher-path-corruption.test.cjs",
|
||||
"bug-730-milestone-phase-details-scope.test.cjs",
|
||||
"bug-782-cline-skills-emission.test.cjs",
|
||||
"bug-783-kilo-global-skills-base.test.cjs",
|
||||
"bug-851-codex-quick-adapter-agent-type-fallback.test.cjs",
|
||||
"bug-853-bg-dispatch-runtime-gating.test.cjs",
|
||||
"bug-866-profile-pipeline-temp-root.test.cjs",
|
||||
"bug-891-non-claude-runtime-home-fallback.test.cjs",
|
||||
@@ -267,7 +220,5 @@
|
||||
"bug-967-verify-key-links-strict-paths.test.cjs",
|
||||
"bug-969-test-infra-flake-hardening.test.cjs",
|
||||
"bug-974-graphify-budget-missing-value.test.cjs",
|
||||
"bug-977-fnm-multishell-path.test.cjs",
|
||||
"bug-978-milestone-complete-force.test.cjs",
|
||||
"bug-983-trae-windsurf-claude-path-leak.test.cjs"
|
||||
"bug-978-milestone-complete-force.test.cjs"
|
||||
]
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
"bug-2943-config-get-context-window-default.test.cjs",
|
||||
"bug-321-config-defaults-clone-strategy.test.cjs",
|
||||
"bug-3227-config-set-model-overrides.test.cjs",
|
||||
"bug-442-config-dir-equals-in-path.test.cjs",
|
||||
"config-field-docs.test.cjs",
|
||||
"config-get-default.test.cjs",
|
||||
"config-schema.property.test.cjs",
|
||||
@@ -110,12 +109,12 @@
|
||||
"bug-3454-state-dollar-backreference-growth.test.cjs",
|
||||
"bug-397-state-preserve-executor-authored.test.cjs",
|
||||
"bug-905-state-syncstatefrontmatter-preserve-scalars.test.cjs",
|
||||
"bug-948-state-noop-write-guard.test.cjs",
|
||||
"state-acquirestatelock-non-eexist.test.cjs",
|
||||
"state-prune.test.cjs",
|
||||
"state-rebuild-cli.test.cjs",
|
||||
"state-rebuild.test.cjs",
|
||||
"state.test.cjs"
|
||||
"bug-948-state-noop-write-guard.test.cjs",
|
||||
"state-acquirestatelock-non-eexist.test.cjs",
|
||||
"state-prune.test.cjs",
|
||||
"state-rebuild-cli.test.cjs",
|
||||
"state-rebuild.test.cjs",
|
||||
"state.test.cjs"
|
||||
],
|
||||
"issue": "180"
|
||||
},
|
||||
@@ -137,10 +136,7 @@
|
||||
},
|
||||
"install": {
|
||||
"files": [
|
||||
"bug-410-install-defaults-test-mode-guard.test.cjs",
|
||||
"enh-1077-install-hook-events-dialect-drive.test.cjs",
|
||||
"enh-1082-install-plan-capstone.test.cjs",
|
||||
"enh-776-install-gemini-hook-events.test.cjs",
|
||||
"install-minimal-hooks.test.cjs",
|
||||
"install-nested-layout.test.cjs",
|
||||
"install-path-detection.test.cjs",
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Bug #130: finishInstall calls configureOpencodePermissions unconditionally,
|
||||
* violating the GSD_TEST_MODE side-effect-free contract.
|
||||
*
|
||||
* configureOpencodePermissions does fs.mkdirSync + fs.writeFileSync, which
|
||||
* must NOT run under GSD_TEST_MODE='1'. This test asserts that the opencode
|
||||
* config file (opencode.json) is NOT created when GSD_TEST_MODE is set.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
|
||||
// Point HOME at a temp dir so configureOpencodePermissions can't write to
|
||||
// the real ~/.config/opencode/ even if the guard is missing.
|
||||
const FAKE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-130-test-'));
|
||||
process.env.HOME = FAKE_HOME;
|
||||
process.env.USERPROFILE = FAKE_HOME;
|
||||
|
||||
// The opencode config dir that configureOpencodePermissions would use for a
|
||||
// global install when configDir=null: <HOME>/.config/opencode/
|
||||
// The file it writes is opencode.json (or opencode.jsonc if pre-existing).
|
||||
const OPENCODE_CONFIG_DIR = path.join(FAKE_HOME, '.config', 'opencode');
|
||||
const OPENCODE_CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'opencode.json');
|
||||
|
||||
// configDir is passed explicitly so the function targets our FAKE_HOME dir
|
||||
// regardless of how getGlobalDir resolves.
|
||||
const installModule = require(path.join(ROOT, 'bin', 'install.js'));
|
||||
|
||||
const SETTINGS_PATH = path.join(FAKE_HOME, `gsd-test-settings-${process.pid}.json`);
|
||||
|
||||
function callFinishInstall() {
|
||||
const original = console.log;
|
||||
console.log = () => {};
|
||||
try {
|
||||
installModule.finishInstall(
|
||||
SETTINGS_PATH,
|
||||
{},
|
||||
null,
|
||||
false,
|
||||
'opencode',
|
||||
true,
|
||||
OPENCODE_CONFIG_DIR, // pass explicit configDir pointing at our temp dir
|
||||
);
|
||||
} finally {
|
||||
console.log = original;
|
||||
}
|
||||
}
|
||||
|
||||
describe('Bug #130: finishInstall opencode + GSD_TEST_MODE side-effect guard', () => {
|
||||
test('configureOpencodePermissions does NOT write opencode.json under GSD_TEST_MODE', () => {
|
||||
// Confirm the file does not exist before the call
|
||||
assert.equal(
|
||||
fs.existsSync(OPENCODE_CONFIG_FILE),
|
||||
false,
|
||||
'opencode.json should not exist before finishInstall call',
|
||||
);
|
||||
|
||||
callFinishInstall();
|
||||
|
||||
// Assert the file was NOT created — the side-effect must be suppressed
|
||||
assert.equal(
|
||||
fs.existsSync(OPENCODE_CONFIG_FILE),
|
||||
false,
|
||||
`opencode.json must NOT be created under GSD_TEST_MODE; found at ${OPENCODE_CONFIG_FILE}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
/**
|
||||
* Regression test for #1736: local Claude install missing commands/gsd/
|
||||
*
|
||||
* After a fresh local install (`--claude --local`), all /gsd-* commands
|
||||
* except /gsd-help return "Unknown skill: gsd-quick" because
|
||||
* .claude/commands/gsd/ was not populated. Claude Code reads local project
|
||||
* commands from .claude/commands/ (one level up) using the file stem as the
|
||||
* command name.
|
||||
*
|
||||
* #1367 follow-up: the fix changed the layout from the old commands/gsd/<cmd>.md
|
||||
* (which caused /gsd:<cmd> colon namespace) to flat commands/gsd-<cmd>.md
|
||||
* (which produces /gsd-<cmd> hyphen form). This test has been updated to assert
|
||||
* the new flat layout while preserving the core invariant from #1736: commands
|
||||
* must be present and usable after a local install.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test, before, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
||||
const { install } = require(INSTALL_SRC);
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
// ─── Ensure hooks/dist/ is populated before install tests ────────────────────
|
||||
// With --test-concurrency=4, other install tests (bug-1834, bug-1924) run
|
||||
// build-hooks.js concurrently. That script creates hooks/dist/ empty first,
|
||||
// then copies files — creating a window where this test sees an empty dir and
|
||||
// install() fails with "directory is empty" → process.exit(1).
|
||||
|
||||
before(() => {
|
||||
execFileSync(process.execPath, [BUILD_SCRIPT], {
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #1736 + #1367: local install deploys commands in flat gsd-<cmd>.md layout ───
|
||||
|
||||
describe('#1736: local Claude install deploys slash commands (flat gsd-<cmd>.md layout, #1367)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-local-install-1736-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Use the shared helper which has a 5s Windows-EBUSY retry budget
|
||||
// (20×250ms). The inline 1s budget here was insufficient on cold runners.
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('local install creates .claude/commands/ directory with flat gsd-*.md files (#1367)', (t) => {
|
||||
// #1736 invariant: commands must be deployed.
|
||||
// #1367 fix: commands land as flat gsd-<cmd>.md at commands/ (not commands/gsd/<cmd>.md).
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
process.chdir(tmpDir);
|
||||
install(false, 'claude');
|
||||
|
||||
const commandsDir = path.join(tmpDir, '.claude', 'commands');
|
||||
assert.ok(
|
||||
fs.existsSync(commandsDir),
|
||||
'.claude/commands/ directory must exist after local install'
|
||||
);
|
||||
const flatFiles = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md'));
|
||||
assert.ok(
|
||||
flatFiles.length > 0,
|
||||
`.claude/commands/ must have flat gsd-*.md files (e.g. gsd-help.md). Found: ${JSON.stringify(flatFiles)}`
|
||||
);
|
||||
// The old commands/gsd/ subdirectory must NOT exist (#1367)
|
||||
const oldSubdir = path.join(commandsDir, 'gsd');
|
||||
assert.ok(
|
||||
!fs.existsSync(oldSubdir),
|
||||
'.claude/commands/gsd/ subdir must NOT exist — flat gsd-<cmd>.md layout required (#1367)'
|
||||
);
|
||||
});
|
||||
|
||||
test('local install deploys at least one .md command file to .claude/commands/ (#1736 invariant)', (t) => {
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
process.chdir(tmpDir);
|
||||
install(false, 'claude');
|
||||
|
||||
const commandsDir = path.join(tmpDir, '.claude', 'commands');
|
||||
assert.ok(
|
||||
fs.existsSync(commandsDir),
|
||||
'.claude/commands/ must exist'
|
||||
);
|
||||
|
||||
const files = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md'));
|
||||
assert.ok(
|
||||
files.length > 0,
|
||||
`.claude/commands/ must contain at least one gsd-*.md file, found: ${JSON.stringify(files)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('local install deploys gsd-quick.md to .claude/commands/ (#1367: flat hyphen form)', (t) => {
|
||||
// Was: .claude/commands/gsd/quick.md (caused /gsd:quick colon form).
|
||||
// Now: .claude/commands/gsd-quick.md (produces /gsd-quick hyphen form).
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
process.chdir(tmpDir);
|
||||
install(false, 'claude');
|
||||
|
||||
const quickCmd = path.join(tmpDir, '.claude', 'commands', 'gsd-quick.md');
|
||||
assert.ok(
|
||||
fs.existsSync(quickCmd),
|
||||
'.claude/commands/gsd-quick.md must exist after local install (#1367 flat layout)'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,110 +0,0 @@
|
||||
/**
|
||||
* Regression tests for bug #1754
|
||||
*
|
||||
* The installer must NOT register .js hook entries in settings.json when the
|
||||
* corresponding .js file does not exist at the target path. The original bug:
|
||||
* on fresh installs where hooks/dist/ was missing from the npm package (as in
|
||||
* v1.32.0), the hook copy step produced no files, yet the registration step
|
||||
* ran unconditionally for .js hooks — leaving users with "PreToolUse:Bash
|
||||
* hook error" on every tool invocation.
|
||||
*
|
||||
* The .sh hooks already had fs.existsSync() guards (added in #1817). This
|
||||
* test verifies the same defensive pattern exists for all .js hooks.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, before } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
// ADR-857 phase 5f-1b: settings-json hook registration moved to runtime-hooks-surface.cts.
|
||||
const HOOKS_SURFACE_SRC = path.join(__dirname, '..', 'src', 'runtime-hooks-surface.cts');
|
||||
|
||||
const JS_HOOKS = [
|
||||
{ name: 'gsd-check-update.js', registrationAnchor: 'hasGsdUpdateHook' },
|
||||
{ name: 'gsd-context-monitor.js', registrationAnchor: 'hasContextMonitorHook' },
|
||||
{ name: 'gsd-prompt-guard.js', registrationAnchor: 'hasPromptGuardHook' },
|
||||
{ name: 'gsd-read-guard.js', registrationAnchor: 'hasReadGuardHook' },
|
||||
{ name: 'gsd-workflow-guard.js', registrationAnchor: 'hasWorkflowGuardHook' },
|
||||
{ name: 'gsd-worktree-path-guard.js', registrationAnchor: 'hasWorktreePathGuardHook' },
|
||||
];
|
||||
|
||||
describe('bug #1754: .js hook registration guards', () => {
|
||||
let src;
|
||||
|
||||
before(() => {
|
||||
// ADR-857 phase 5f-1b: hook registration moved to runtime-hooks-surface.cts.
|
||||
// Concatenate both sources so structural assertions find patterns in either file.
|
||||
const installSrc = fs.readFileSync(INSTALL_SRC, 'utf-8');
|
||||
let hooksSurfaceSrc = '';
|
||||
try { hooksSurfaceSrc = fs.readFileSync(HOOKS_SURFACE_SRC, 'utf-8'); } catch { /* ok */ }
|
||||
src = installSrc + '\n' + hooksSurfaceSrc;
|
||||
});
|
||||
|
||||
for (const { name, registrationAnchor } of JS_HOOKS) {
|
||||
describe(`${name} registration`, () => {
|
||||
test(`install.js checks file existence before registering ${name}`, () => {
|
||||
// Find the registration block by locating the "has...Hook" variable
|
||||
const anchorIdx = src.indexOf(registrationAnchor);
|
||||
assert.ok(
|
||||
anchorIdx !== -1,
|
||||
`${registrationAnchor} variable not found in install.js`
|
||||
);
|
||||
|
||||
// Extract a window around the registration block to find the guard
|
||||
const blockStart = anchorIdx;
|
||||
const blockEnd = Math.min(src.length, anchorIdx + 1200);
|
||||
const block = src.slice(blockStart, blockEnd);
|
||||
|
||||
// The block must contain an fs.existsSync check for the hook file
|
||||
assert.ok(
|
||||
block.includes('fs.existsSync') || block.includes('existsSync'),
|
||||
`install.js must call fs.existsSync on the target path before registering ${name} ` +
|
||||
`in settings.json. Without this guard, hooks are registered even when the .js file ` +
|
||||
`was never copied (the root cause of #1754).`
|
||||
);
|
||||
});
|
||||
|
||||
test(`install.js emits a warning when ${name} is missing`, () => {
|
||||
// The hook file name (without extension) should appear in a warning message
|
||||
const hookBaseName = name.replace('.js', '');
|
||||
const warnPattern = `Skipped`;
|
||||
const anchorIdx = src.indexOf(registrationAnchor);
|
||||
const block = src.slice(anchorIdx, Math.min(src.length, anchorIdx + 1200));
|
||||
|
||||
assert.ok(
|
||||
block.includes(warnPattern) && block.includes(hookBaseName),
|
||||
`install.js must emit a skip warning when ${name} is not found at the target path`
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('all .js hooks use the same guard pattern as .sh hooks', () => {
|
||||
// Count existsSync calls in the hook registration section.
|
||||
// There should be guards for all JS hooks plus the existing SH hooks.
|
||||
// This test ensures new hooks added in the future follow the same pattern.
|
||||
// ADR-857 phase 5f-1b: registration moved to runtime-hooks-surface.cts so scan the
|
||||
// full concatenated source (install.js + runtime-hooks-surface.cts) rather than slicing.
|
||||
const registrationSection = src;
|
||||
|
||||
// Count unique hook file existence checks (pattern: path.join(targetDir, 'hooks', 'gsd-*.js'))
|
||||
const jsGuards = (registrationSection.match(/gsd-[\w-]+\.js.*not found at target/g) || []);
|
||||
const shGuards = (registrationSection.match(/gsd-[\w-]+\.sh.*not found at target/g) || []);
|
||||
|
||||
assert.ok(
|
||||
jsGuards.length >= JS_HOOKS.length,
|
||||
`Expected at least ${JS_HOOKS.length} .js hook guards, found ${jsGuards.length}. ` +
|
||||
`Every .js hook registration must check file existence before registering.`
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
shGuards.length >= 3,
|
||||
`Expected at least 3 .sh hook guards (validate-commit, session-state, phase-boundary), ` +
|
||||
`found ${shGuards.length}.`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* Regression tests for bug #1817
|
||||
*
|
||||
* The installer must NOT register .sh hook entries in settings.json when the
|
||||
* corresponding .sh file does not exist at the target path. The original bug:
|
||||
* v1.32.0's npm package omitted the .sh files from hooks/dist/, so the copy
|
||||
* step produced no files, yet the registration step ran unconditionally —
|
||||
* leaving users with hook errors on every tool invocation.
|
||||
*
|
||||
* Defensive guard: before registering each .sh hook in settings.json,
|
||||
* install.js must verify the target file exists. If it doesn't, skip
|
||||
* registration and emit a warning.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
// ADR-857 phase 5f-1b: settings-json hook registration moved to runtime-hooks-surface.cts.
|
||||
const HOOKS_SURFACE_SRC = path.join(__dirname, '..', 'src', 'runtime-hooks-surface.cts');
|
||||
|
||||
const SH_HOOKS = [
|
||||
{ name: 'gsd-validate-commit.sh', settingsVar: 'validateCommitCommand' },
|
||||
{ name: 'gsd-session-state.sh', settingsVar: 'sessionStateCommand' },
|
||||
{ name: 'gsd-phase-boundary.sh', settingsVar: 'phaseBoundaryCommand' },
|
||||
];
|
||||
|
||||
describe('bug #1817: .sh hook registration guards', () => {
|
||||
let src;
|
||||
|
||||
// Read once — all tests in this suite share the same source snapshot.
|
||||
// ADR-857 phase 5f-1b: hook registration moved to runtime-hooks-surface.cts.
|
||||
// Concatenate both sources so structural assertions find patterns in either file.
|
||||
try {
|
||||
const installSrc = fs.readFileSync(INSTALL_SRC, 'utf-8');
|
||||
let hooksSurfaceSrc = '';
|
||||
try { hooksSurfaceSrc = fs.readFileSync(HOOKS_SURFACE_SRC, 'utf-8'); } catch { /* ok */ }
|
||||
src = installSrc + '\n' + hooksSurfaceSrc;
|
||||
} catch {
|
||||
src = '';
|
||||
}
|
||||
|
||||
for (const { name, settingsVar } of SH_HOOKS) {
|
||||
describe(`${name} registration`, () => {
|
||||
test(`install.js checks file existence before registering ${name}`, () => {
|
||||
// Find the block where this .sh hook is registered.
|
||||
// Each registration block is preceded by the command variable declaration
|
||||
// and followed by the next hook or end of registration section.
|
||||
const varIdx = src.indexOf(settingsVar);
|
||||
assert.ok(varIdx !== -1, `${settingsVar} variable not found in install.js`);
|
||||
|
||||
// Extract ~900 chars around the variable to find the registration block
|
||||
const blockStart = Math.max(0, varIdx - 50);
|
||||
const blockEnd = Math.min(src.length, varIdx + 900);
|
||||
const block = src.slice(blockStart, blockEnd);
|
||||
|
||||
assert.ok(
|
||||
block.includes('fs.existsSync') || block.includes('existsSync'),
|
||||
`install.js must call fs.existsSync on the target path before registering ${name} in settings.json. ` +
|
||||
`Without this guard, hooks are registered even when the .sh file was never copied ` +
|
||||
`(the root cause of #1817).`
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,134 +0,0 @@
|
||||
/**
|
||||
* Regression test for bug #1908
|
||||
*
|
||||
* `--uninstall` did not remove `gsd-file-manifest.json` from the target
|
||||
* directory, leaving a stale metadata file after uninstall.
|
||||
*
|
||||
* Fix: `uninstall()` must call
|
||||
* fs.rmSync(path.join(targetDir, MANIFEST_NAME), { force: true })
|
||||
* after cleaning up the rest of the GSD artefacts.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
const { uninstall } = require('../bin/install.js');
|
||||
|
||||
const MANIFEST_NAME = 'gsd-file-manifest.json';
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function createFakeInstall(prefix = 'gsd-uninstall-test-') {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
|
||||
// Simulate the minimum directory/file layout produced by the installer:
|
||||
// gsd-core/ directory, agents/ directory, and the manifest file.
|
||||
fs.mkdirSync(path.join(dir, 'gsd-core', 'workflows'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'gsd-core', 'workflows', 'execute-phase.md'), '# stub');
|
||||
|
||||
fs.mkdirSync(path.join(dir, 'agents'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'agents', 'gsd-executor.md'), '# stub');
|
||||
|
||||
const manifest = {
|
||||
version: '1.34.0',
|
||||
timestamp: new Date().toISOString(),
|
||||
files: {
|
||||
'gsd-core/workflows/execute-phase.md': 'abc123',
|
||||
'agents/gsd-executor.md': 'def456',
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(path.join(dir, MANIFEST_NAME), JSON.stringify(manifest, null, 2));
|
||||
|
||||
return dir;
|
||||
}
|
||||
|
||||
function cleanup(dir) {
|
||||
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- local teardown helper predates helpers.cjs; renaming would collide with the imported cleanup
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
|
||||
// ─── tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('uninstall — manifest cleanup (#1908)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createFakeInstall();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('gsd-file-manifest.json is removed after global uninstall', () => {
|
||||
const manifestPath = path.join(tmpDir, MANIFEST_NAME);
|
||||
|
||||
// Pre-condition: manifest exists before uninstall
|
||||
assert.ok(
|
||||
fs.existsSync(manifestPath),
|
||||
'Test setup failure: manifest file should exist before uninstall'
|
||||
);
|
||||
|
||||
// Run uninstall against tmpDir (pass it via CLAUDE_CONFIG_DIR so getGlobalDir()
|
||||
// resolves to our temp directory; pass isGlobal=true)
|
||||
const savedEnv = process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir;
|
||||
try {
|
||||
uninstall(true, 'claude');
|
||||
} finally {
|
||||
if (savedEnv === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = savedEnv;
|
||||
}
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
!fs.existsSync(manifestPath),
|
||||
[
|
||||
`${MANIFEST_NAME} must be removed by uninstall() but still exists at`,
|
||||
manifestPath,
|
||||
].join(' ')
|
||||
);
|
||||
});
|
||||
|
||||
test('gsd-file-manifest.json is removed after local uninstall', () => {
|
||||
const manifestPath = path.join(tmpDir, MANIFEST_NAME);
|
||||
|
||||
assert.ok(
|
||||
fs.existsSync(manifestPath),
|
||||
'Test setup failure: manifest file should exist before uninstall'
|
||||
);
|
||||
|
||||
// For a local install, getGlobalDir is not called — targetDir = cwd + dirName.
|
||||
// Simulate by creating .claude/ inside tmpDir and placing artefacts there.
|
||||
const localDir = path.join(tmpDir, '.claude');
|
||||
fs.mkdirSync(path.join(localDir, 'gsd-core', 'workflows'), { recursive: true });
|
||||
fs.writeFileSync(path.join(localDir, 'gsd-core', 'workflows', 'execute-phase.md'), '# stub');
|
||||
const localManifestPath = path.join(localDir, MANIFEST_NAME);
|
||||
fs.writeFileSync(localManifestPath, JSON.stringify({ version: '1.34.0', files: {} }, null, 2));
|
||||
|
||||
const savedCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
try {
|
||||
uninstall(false, 'claude');
|
||||
} finally {
|
||||
process.chdir(savedCwd);
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
!fs.existsSync(localManifestPath),
|
||||
[
|
||||
`${MANIFEST_NAME} must be removed by uninstall() (local) but still exists at`,
|
||||
localManifestPath,
|
||||
].join(' ')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,278 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Regression tests for bug #1924: gsd-update silently deletes user-generated files
|
||||
*
|
||||
* Running the installer (gsd-update / re-install) must not delete:
|
||||
* - gsd-core/USER-PROFILE.md (created by /gsd-profile-user)
|
||||
* - commands/gsd/dev-preferences.md (created by /gsd-profile-user)
|
||||
*
|
||||
* Root cause:
|
||||
* 1. copyWithPathReplacement() calls fs.rmSync(destDir, {recursive:true}) before
|
||||
* copying — no preserve allowlist. This wipes USER-PROFILE.md.
|
||||
* 2. ~line 5211 explicitly rmSync's commands/gsd/ during global install legacy
|
||||
* cleanup — no preserve. This wipes dev-preferences.md.
|
||||
*
|
||||
* Fix requirement:
|
||||
* - install() must preserve USER-PROFILE.md across the gsd-core/ wipe
|
||||
* - install() must preserve dev-preferences.md across the commands/gsd/ wipe
|
||||
*
|
||||
* Closes: #1924
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, beforeEach, afterEach, before } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
||||
|
||||
// ─── Ensure hooks/dist/ is populated before any install test ─────────────────
|
||||
|
||||
before(() => {
|
||||
execFileSync(process.execPath, [BUILD_SCRIPT], {
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dir) {
|
||||
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- local cleanup() helper wrapping rmSync; cannot use imported cleanup() without naming collision
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the installer with CLAUDE_CONFIG_DIR redirected to a temp directory.
|
||||
* Explicitly removes GSD_TEST_MODE so the subprocess actually runs the installer
|
||||
* (not just the export block). Uses --yes to suppress interactive prompts.
|
||||
*/
|
||||
function runInstaller(configDir) {
|
||||
const env = { ...process.env, CLAUDE_CONFIG_DIR: configDir };
|
||||
delete env.GSD_TEST_MODE;
|
||||
// --no-sdk: this test covers user-artifact preservation only; skip SDK
|
||||
// build (covered by install-smoke.yml) to keep the test deterministic.
|
||||
execFileSync(process.execPath, [INSTALL_SCRIPT, '--claude', '--global', '--yes', '--no-sdk'], {
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Test 1: USER-PROFILE.md is preserved across re-install ─────────────────
|
||||
|
||||
describe('#1924: USER-PROFILE.md preserved across re-install (global Claude)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-1924-userprofile-');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('USER-PROFILE.md exists after initial install + user creation', () => {
|
||||
runInstaller(tmpDir);
|
||||
|
||||
// Simulate /gsd-profile-user creating USER-PROFILE.md inside gsd-core/
|
||||
const profilePath = path.join(tmpDir, 'gsd-core', 'USER-PROFILE.md');
|
||||
fs.writeFileSync(profilePath, '# My Profile\n\nCustom user content.\n');
|
||||
|
||||
assert.ok(
|
||||
fs.existsSync(profilePath),
|
||||
'USER-PROFILE.md should exist after being created by /gsd-profile-user'
|
||||
);
|
||||
});
|
||||
|
||||
test('USER-PROFILE.md is preserved after re-install', () => {
|
||||
// First install
|
||||
runInstaller(tmpDir);
|
||||
|
||||
// User runs /gsd-profile-user, creating USER-PROFILE.md
|
||||
const profilePath = path.join(tmpDir, 'gsd-core', 'USER-PROFILE.md');
|
||||
const originalContent = '# My Profile\n\nThis is my custom user profile content.\n';
|
||||
fs.writeFileSync(profilePath, originalContent);
|
||||
|
||||
// Re-run installer (simulating gsd-update)
|
||||
runInstaller(tmpDir);
|
||||
|
||||
assert.ok(
|
||||
fs.existsSync(profilePath),
|
||||
'USER-PROFILE.md must survive re-install — gsd-update must not delete user-generated profiles'
|
||||
);
|
||||
|
||||
const afterContent = fs.readFileSync(profilePath, 'utf8');
|
||||
assert.strictEqual(
|
||||
afterContent,
|
||||
originalContent,
|
||||
'USER-PROFILE.md content must be identical after re-install'
|
||||
);
|
||||
});
|
||||
|
||||
test('USER-PROFILE.md is preserved even when gsd-core/ is wiped and recreated', () => {
|
||||
runInstaller(tmpDir);
|
||||
|
||||
const gsdDir = path.join(tmpDir, 'gsd-core');
|
||||
const profilePath = path.join(gsdDir, 'USER-PROFILE.md');
|
||||
|
||||
// Confirm gsd-core/ was created by install
|
||||
assert.ok(fs.existsSync(gsdDir), 'gsd-core/ must exist after install');
|
||||
|
||||
// Write profile
|
||||
fs.writeFileSync(profilePath, '# Profile\n\nMy coding style preferences.\n');
|
||||
|
||||
// Re-install
|
||||
runInstaller(tmpDir);
|
||||
|
||||
// gsd-core/ must still exist AND profile must be intact
|
||||
assert.ok(fs.existsSync(gsdDir), 'gsd-core/ must still exist after re-install');
|
||||
assert.ok(
|
||||
fs.existsSync(profilePath),
|
||||
'USER-PROFILE.md must still exist after gsd-core/ was wiped and recreated'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test 2: dev-preferences.md is preserved across re-install ───────────────
|
||||
|
||||
describe('#1924: dev-preferences.md preserved across re-install (global Claude)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-1924-devprefs-');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('dev-preferences.md is preserved when commands/gsd/ is cleaned up during re-install', () => {
|
||||
// First install (creates skills/ structure for global Claude)
|
||||
runInstaller(tmpDir);
|
||||
|
||||
// User runs /gsd-profile-user — it creates dev-preferences.md in commands/gsd/
|
||||
const commandsGsdDir = path.join(tmpDir, 'commands', 'gsd');
|
||||
fs.mkdirSync(commandsGsdDir, { recursive: true });
|
||||
const devPrefsPath = path.join(commandsGsdDir, 'dev-preferences.md');
|
||||
const originalContent = '# Dev Preferences\n\nI prefer TDD. I like short functions.\n';
|
||||
fs.writeFileSync(devPrefsPath, originalContent);
|
||||
|
||||
// Re-run installer (simulating gsd-update).
|
||||
// In the layout-driven path (B2), legacy commands/gsd/ is removed and
|
||||
// dev-preferences.md is migrated to skills/gsd-dev-preferences/SKILL.md (#2973).
|
||||
runInstaller(tmpDir);
|
||||
|
||||
// Content is migrated to the new canonical skills location (#2973).
|
||||
// The old commands/gsd/ path is cleaned up; the skill file carries the content.
|
||||
const devPrefSkillPath = path.join(tmpDir, 'skills', 'gsd-dev-preferences', 'SKILL.md');
|
||||
assert.ok(
|
||||
fs.existsSync(devPrefSkillPath),
|
||||
'dev-preferences.md must be migrated to skills/gsd-dev-preferences/SKILL.md — gsd-update legacy cleanup must not silently drop user-generated content'
|
||||
);
|
||||
|
||||
const afterContent = fs.readFileSync(devPrefSkillPath, 'utf8');
|
||||
assert.strictEqual(
|
||||
afterContent,
|
||||
originalContent,
|
||||
'migrated dev-preferences content must be identical to the original'
|
||||
);
|
||||
});
|
||||
|
||||
test('legacy non-user GSD commands are still cleaned up during re-install', () => {
|
||||
// First install
|
||||
runInstaller(tmpDir);
|
||||
|
||||
// Simulate a legacy GSD command file being left in commands/gsd/
|
||||
const commandsGsdDir = path.join(tmpDir, 'commands', 'gsd');
|
||||
fs.mkdirSync(commandsGsdDir, { recursive: true });
|
||||
const legacyFile = path.join(commandsGsdDir, 'next.md');
|
||||
fs.writeFileSync(legacyFile, '---\nname: gsd:next\n---\n\nLegacy content.');
|
||||
|
||||
// But dev-preferences.md is also there (user-generated)
|
||||
const devPrefsContent = '# Dev Preferences\n\nMy preferences.\n';
|
||||
const devPrefsPath = path.join(commandsGsdDir, 'dev-preferences.md');
|
||||
fs.writeFileSync(devPrefsPath, devPrefsContent);
|
||||
|
||||
// Re-install
|
||||
runInstaller(tmpDir);
|
||||
|
||||
// In the layout-driven path (B2), commands/gsd/ is fully removed but
|
||||
// dev-preferences.md content is migrated to the new canonical skill location.
|
||||
const devPrefSkillPath = path.join(tmpDir, 'skills', 'gsd-dev-preferences', 'SKILL.md');
|
||||
assert.ok(
|
||||
fs.existsSync(devPrefSkillPath),
|
||||
'dev-preferences.md content must be migrated to skills/gsd-dev-preferences/SKILL.md'
|
||||
);
|
||||
|
||||
// The legacy GSD command (next.md) is NOT user-generated, must be removed
|
||||
// (it would exist only as a skill now in skills/gsd-next/SKILL.md)
|
||||
assert.ok(
|
||||
!fs.existsSync(legacyFile),
|
||||
'legacy GSD command next.md in commands/gsd/ must be removed during cleanup'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test 3: profile-user.md backup path is outside gsd-core/ ───────────
|
||||
|
||||
describe('#1924: profile-user.md backup path must be outside gsd-core/', () => {
|
||||
test('profile-user.md backup uses ~/.claude/USER-PROFILE.backup.md not ~/.claude/gsd-core/USER-PROFILE.backup.md', () => {
|
||||
const workflowPath = path.join(
|
||||
__dirname, '..', 'gsd-core', 'workflows', 'profile-user.md'
|
||||
);
|
||||
const content = fs.readFileSync(workflowPath, 'utf8');
|
||||
|
||||
// The backup must NOT be inside gsd-core/ because that directory is wiped on update
|
||||
assert.ok(
|
||||
!content.includes('gsd-core/USER-PROFILE.backup.md'),
|
||||
'backup path must NOT be inside gsd-core/ — that directory is wiped on gsd-update'
|
||||
);
|
||||
|
||||
// The backup should be at ~/.claude/USER-PROFILE.backup.md (outside gsd-core/)
|
||||
assert.ok(
|
||||
content.includes('USER-PROFILE.backup.md') &&
|
||||
!content.includes('/gsd-core/USER-PROFILE.backup.md'),
|
||||
'backup path must be outside gsd-core/ (e.g. ~/.claude/USER-PROFILE.backup.md)'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test 4: preserveUserArtifacts helper exported from install.js ────────────
|
||||
|
||||
describe('#1924: preserveUserArtifacts helper exists in install.js', () => {
|
||||
test('install.js exports preserveUserArtifacts function', () => {
|
||||
// Set GSD_TEST_MODE so require() reaches the module.exports block
|
||||
const origMode = process.env.GSD_TEST_MODE;
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
let mod;
|
||||
try {
|
||||
mod = require(INSTALL_SCRIPT);
|
||||
} finally {
|
||||
if (origMode === undefined) {
|
||||
delete process.env.GSD_TEST_MODE;
|
||||
} else {
|
||||
process.env.GSD_TEST_MODE = origMode;
|
||||
}
|
||||
}
|
||||
|
||||
assert.strictEqual(
|
||||
typeof mod.preserveUserArtifacts,
|
||||
'function',
|
||||
'install.js must export preserveUserArtifacts helper for testability'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,147 +0,0 @@
|
||||
/**
|
||||
* Regression test for #2248: local Claude install clobbers profile-level statusLine
|
||||
*
|
||||
* When installing with `--claude --local`, the repo-level `.claude/settings.json`
|
||||
* takes precedence over the user's profile-level `~/.claude/settings.json` in
|
||||
* Claude Code. Writing `statusLine` to repo settings during a local install
|
||||
* silently overrides any profile-level statusLine the user configured.
|
||||
*
|
||||
* Fix: local installs skip writing `statusLine` to settings.json unless
|
||||
* `--force-statusline` is passed.
|
||||
*
|
||||
* Note: `install()` only copies files. `finishInstall()` writes settings.json.
|
||||
* The production code calls both from `installAllRuntimes()`. Tests must mirror
|
||||
* that two-phase pattern.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test, before, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
||||
const { install, finishInstall } = require(INSTALL_SRC);
|
||||
const { cleanup, captureConsole } = require('./helpers.cjs');
|
||||
|
||||
// ─── Ensure hooks/dist/ is populated before install tests ────────────────────
|
||||
before(() => {
|
||||
execFileSync(process.execPath, [BUILD_SCRIPT], {
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #2248: local install must NOT write statusLine to repo settings.json ────
|
||||
|
||||
describe('#2248: local Claude install does not clobber profile-level statusLine', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-local-install-2248-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Use the shared 5s Windows-EBUSY retry budget instead of inline 1s.
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('local install writes hooks to .claude/settings.local.json and does not write statusLine', (t) => {
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
process.chdir(tmpDir);
|
||||
|
||||
// Phase 1: copy files (mirrors installAllRuntimes)
|
||||
const result = install(false, 'claude');
|
||||
|
||||
// Phase 2: configure settings.local.json (mirrors installAllRuntimes → finalize)
|
||||
// #338: local Claude installs now write to settings.local.json, not settings.json.
|
||||
// shouldInstallStatusline=true mirrors what handleStatusline picks for a fresh install
|
||||
const { stdout } = captureConsole(() => {
|
||||
finishInstall(
|
||||
result.settingsPath,
|
||||
result.settings,
|
||||
result.statuslineCommand,
|
||||
true, // shouldInstallStatusline
|
||||
'claude',
|
||||
false // isGlobal=false -> local install
|
||||
);
|
||||
});
|
||||
assert.match(
|
||||
stdout,
|
||||
/Skipping statusLine for local install/,
|
||||
'Local install must explain that it skipped statusLine unless --force-statusline is passed'
|
||||
);
|
||||
|
||||
// #338: local installs write to settings.local.json, not settings.json
|
||||
const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json');
|
||||
assert.ok(
|
||||
fs.existsSync(localSettingsPath),
|
||||
'.claude/settings.local.json must exist after local Claude install (#338)'
|
||||
);
|
||||
|
||||
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
|
||||
assert.strictEqual(
|
||||
settings.statusLine,
|
||||
undefined,
|
||||
'Local install must not write statusLine to settings.local.json — it would clobber profile-level settings (#2248)'
|
||||
);
|
||||
|
||||
// settings.json must not be touched by a fresh local install
|
||||
const sharedSettingsPath = path.join(tmpDir, '.claude', 'settings.json');
|
||||
assert.strictEqual(
|
||||
fs.existsSync(sharedSettingsPath),
|
||||
false,
|
||||
'.claude/settings.json must NOT be created by a fresh local Claude install (#338)'
|
||||
);
|
||||
});
|
||||
|
||||
test('global install still writes statusLine to settings.json', (t) => {
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
|
||||
// Global install writes to CLAUDE_CONFIG_DIR; point it at our tmpDir
|
||||
const configDir = path.join(tmpDir, '.claude');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
const origEnv = process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir;
|
||||
t.after(() => {
|
||||
if (origEnv === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = origEnv;
|
||||
}
|
||||
});
|
||||
|
||||
// Phase 1: copy files
|
||||
const result = install(true, 'claude');
|
||||
|
||||
// Phase 2: configure settings.json
|
||||
finishInstall(
|
||||
result.settingsPath,
|
||||
result.settings,
|
||||
result.statuslineCommand,
|
||||
true, // shouldInstallStatusline
|
||||
'claude',
|
||||
true // isGlobal=true
|
||||
);
|
||||
|
||||
const settingsPath = path.join(configDir, 'settings.json');
|
||||
assert.ok(
|
||||
fs.existsSync(settingsPath),
|
||||
'~/.claude/settings.json must exist after global install'
|
||||
);
|
||||
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
||||
assert.ok(
|
||||
settings.statusLine !== undefined,
|
||||
'Global install should write statusLine to settings.json'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,159 +0,0 @@
|
||||
/**
|
||||
* Regression tests for issue #2256 — per-agent model_overrides transport
|
||||
* for Codex and OpenCode runtimes.
|
||||
*
|
||||
* The bug: model_overrides set in per-project `.planning/config.json` were
|
||||
* never read by the Codex / OpenCode install paths, which only probed
|
||||
* `~/.gsd/defaults.json`. As a result, the configured per-agent model was
|
||||
* dropped and child agents inherited the runtime's default model.
|
||||
*
|
||||
* These tests lock in the fix: per-project overrides must be honored, and
|
||||
* per-project keys must win over global when both are present.
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
const {
|
||||
readGsdEffectiveModelOverrides,
|
||||
generateCodexAgentToml,
|
||||
convertClaudeToOpencodeFrontmatter,
|
||||
getCodexSkillAdapterHeader,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
const makeTmp = (prefix) => createTempDir(`gsd-2256-${prefix}-`);
|
||||
|
||||
function writeJson(p, obj) {
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, JSON.stringify(obj, null, 2));
|
||||
}
|
||||
|
||||
describe('bug #2256 — readGsdEffectiveModelOverrides', () => {
|
||||
let projectDir;
|
||||
let homeDir;
|
||||
let origHome;
|
||||
let origUserProfile;
|
||||
|
||||
beforeEach(() => {
|
||||
projectDir = makeTmp('proj');
|
||||
homeDir = makeTmp('home');
|
||||
origHome = process.env.HOME;
|
||||
// On Windows, os.homedir() reads USERPROFILE (not HOME). Tests that
|
||||
// need to redirect ~ must override both — otherwise the SUT reads
|
||||
// the real user's home and the fixture is invisible.
|
||||
origUserProfile = process.env.USERPROFILE;
|
||||
process.env.HOME = homeDir;
|
||||
if (isWindows) process.env.USERPROFILE = homeDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (origHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = origHome;
|
||||
if (isWindows) {
|
||||
if (origUserProfile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = origUserProfile;
|
||||
}
|
||||
cleanup(projectDir);
|
||||
cleanup(homeDir);
|
||||
});
|
||||
|
||||
test('returns null when neither source defines model_overrides', () => {
|
||||
const result = readGsdEffectiveModelOverrides(projectDir);
|
||||
assert.strictEqual(result, null);
|
||||
});
|
||||
|
||||
test('reads overrides from ~/.gsd/defaults.json (global only)', () => {
|
||||
writeJson(path.join(homeDir, '.gsd', 'defaults.json'), {
|
||||
model_overrides: { 'gsd-codebase-mapper': 'gpt-5-mini' },
|
||||
});
|
||||
const result = readGsdEffectiveModelOverrides(projectDir);
|
||||
assert.deepStrictEqual(result, { 'gsd-codebase-mapper': 'gpt-5-mini' });
|
||||
});
|
||||
|
||||
test('reads overrides from per-project .planning/config.json', () => {
|
||||
writeJson(path.join(projectDir, '.planning', 'config.json'), {
|
||||
model_overrides: { 'gsd-codebase-mapper': 'claude-haiku-4-5' },
|
||||
});
|
||||
const result = readGsdEffectiveModelOverrides(projectDir);
|
||||
assert.deepStrictEqual(result, { 'gsd-codebase-mapper': 'claude-haiku-4-5' });
|
||||
});
|
||||
|
||||
test('per-project overrides win over global on conflict', () => {
|
||||
writeJson(path.join(homeDir, '.gsd', 'defaults.json'), {
|
||||
model_overrides: { 'gsd-codebase-mapper': 'global-model', 'gsd-planner': 'opus' },
|
||||
});
|
||||
writeJson(path.join(projectDir, '.planning', 'config.json'), {
|
||||
model_overrides: { 'gsd-codebase-mapper': 'project-model' },
|
||||
});
|
||||
const result = readGsdEffectiveModelOverrides(projectDir);
|
||||
// Per-project wins on conflict; non-conflicting global keys are preserved.
|
||||
assert.deepStrictEqual(result, {
|
||||
'gsd-codebase-mapper': 'project-model',
|
||||
'gsd-planner': 'opus',
|
||||
});
|
||||
});
|
||||
|
||||
test('walks up from nested targetDir to find .planning/', () => {
|
||||
writeJson(path.join(projectDir, '.planning', 'config.json'), {
|
||||
model_overrides: { 'gsd-planner': 'project-opus' },
|
||||
});
|
||||
const nested = path.join(projectDir, '.codex');
|
||||
fs.mkdirSync(nested, { recursive: true });
|
||||
const result = readGsdEffectiveModelOverrides(nested);
|
||||
assert.deepStrictEqual(result, { 'gsd-planner': 'project-opus' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('bug #2256 — Codex adapter embeds per-project override', () => {
|
||||
const agentContent = `---\nname: gsd-codebase-mapper\ndescription: Maps codebase\n---\n\nbody\n`;
|
||||
|
||||
test('generateCodexAgentToml embeds model when override provided', () => {
|
||||
const toml = generateCodexAgentToml(
|
||||
'gsd-codebase-mapper',
|
||||
agentContent,
|
||||
{ 'gsd-codebase-mapper': 'gpt-5-mini' },
|
||||
);
|
||||
assert.match(toml, /^model = "gpt-5-mini"$/m);
|
||||
});
|
||||
|
||||
test('generateCodexAgentToml omits model when no override', () => {
|
||||
const toml = generateCodexAgentToml('gsd-codebase-mapper', agentContent, null);
|
||||
assert.doesNotMatch(toml, /^model\s*=/m);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bug #2256 — OpenCode adapter embeds per-project override', () => {
|
||||
test('convertClaudeToOpencodeFrontmatter embeds model on agent frontmatter', () => {
|
||||
const input = `---\nname: gsd-codebase-mapper\ndescription: Maps codebase\n---\n\nbody\n`;
|
||||
const out = convertClaudeToOpencodeFrontmatter(input, {
|
||||
isAgent: true,
|
||||
modelOverride: 'claude-haiku-4-5',
|
||||
});
|
||||
assert.match(out, /^model: claude-haiku-4-5$/m);
|
||||
assert.match(out, /^mode: subagent$/m);
|
||||
});
|
||||
|
||||
test('convertClaudeToOpencodeFrontmatter omits model when override absent', () => {
|
||||
const input = `---\nname: gsd-codebase-mapper\ndescription: Maps codebase\n---\n\nbody\n`;
|
||||
const out = convertClaudeToOpencodeFrontmatter(input, { isAgent: true, modelOverride: null });
|
||||
assert.doesNotMatch(out, /^model:/m);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bug #2256 — Codex skill adapter header documents transport', () => {
|
||||
test('Task(model=...) line no longer says "omit" without explanation', () => {
|
||||
const header = getCodexSkillAdapterHeader('gsd-plan-phase');
|
||||
// Header must mention that per-agent model_overrides are embedded in agent
|
||||
// TOML so spawn_agent picks them up automatically — the old text said
|
||||
// "Codex uses per-role config, not inline model selection" which left
|
||||
// users thinking their model_overrides were silently ignored.
|
||||
assert.match(header, /model_overrides/);
|
||||
});
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* Regression test for #2376: @$HOME not correctly mapped in OpenCode on Windows.
|
||||
*
|
||||
* On Windows, $HOME is not expanded by PowerShell/cmd.exe, so OpenCode cannot
|
||||
* resolve @$HOME/... file references in installed command files.
|
||||
*
|
||||
* Fix: install.js must use the absolute path (not $HOME-relative) when installing
|
||||
* for OpenCode. (Generalized to all platforms in #2831 — OpenCode `@file`
|
||||
* references are not shell-expanded on any platform.)
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
let computePathPrefix;
|
||||
|
||||
before(() => {
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
// Re-require fresh in case other tests already loaded it.
|
||||
delete require.cache[require.resolve('../bin/install.js')];
|
||||
({ computePathPrefix } = require('../bin/install.js'));
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.GSD_TEST_MODE;
|
||||
});
|
||||
|
||||
describe('bug-2376: OpenCode on Windows must use absolute path, not $HOME', () => {
|
||||
test('computePathPrefix is exported by install.js', () => {
|
||||
assert.equal(typeof computePathPrefix, 'function');
|
||||
});
|
||||
|
||||
test('OpenCode on Windows: pathPrefix is absolute (no $HOME substitution)', () => {
|
||||
const pathPrefix = computePathPrefix({
|
||||
isGlobal: true,
|
||||
isOpencode: true,
|
||||
isWindowsHost: true,
|
||||
resolvedTarget: 'C:/Users/user/.config/opencode',
|
||||
homeDir: 'C:/Users/user',
|
||||
});
|
||||
assert.strictEqual(pathPrefix, 'C:/Users/user/.config/opencode/');
|
||||
assert.ok(!pathPrefix.includes('$HOME'));
|
||||
});
|
||||
|
||||
test('Claude Code on Windows: pathPrefix still uses $HOME (unaffected)', () => {
|
||||
const pathPrefix = computePathPrefix({
|
||||
isGlobal: true,
|
||||
isOpencode: false,
|
||||
isWindowsHost: true,
|
||||
resolvedTarget: 'C:/Users/user/.claude',
|
||||
homeDir: 'C:/Users/user',
|
||||
});
|
||||
assert.strictEqual(pathPrefix, '$HOME/.claude/');
|
||||
});
|
||||
});
|
||||
@@ -1,146 +0,0 @@
|
||||
/**
|
||||
* Bug #2418: Found unreplaced .claude path reference(s) in Antigravity install
|
||||
*
|
||||
* The Antigravity path converter handles ~/.claude/ (with trailing slash) but
|
||||
* misses bare ~/.claude (without trailing slash), leaving unreplaced references
|
||||
* that cause the installer to warn about leaked paths.
|
||||
*
|
||||
* Files affected: agents/gsd-debugger.md (configDir = ~/.claude) and
|
||||
* gsd-core/workflows/update.md (comment with e.g. ~/.claude).
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { convertClaudeToAntigravityContent } = require('../bin/install.js');
|
||||
|
||||
describe('convertClaudeToAntigravityContent bare path replacement (#2418)', () => {
|
||||
describe('global install', () => {
|
||||
test('replaces ~/.claude (bare, no trailing slash) with ~/.gemini/antigravity', () => {
|
||||
const input = 'configDir = ~/.claude';
|
||||
const result = convertClaudeToAntigravityContent(input, true);
|
||||
assert.ok(
|
||||
result.includes('~/.gemini/antigravity'),
|
||||
`Expected ~/.gemini/antigravity in output, got: ${result}`
|
||||
);
|
||||
assert.ok(
|
||||
!result.includes('~/.claude'),
|
||||
`Expected ~/ .claude to be replaced, got: ${result}`
|
||||
);
|
||||
});
|
||||
|
||||
test('replaces $HOME/.claude (bare, no trailing slash) with $HOME/.gemini/antigravity', () => {
|
||||
const input = 'export DIR=$HOME/.claude';
|
||||
const result = convertClaudeToAntigravityContent(input, true);
|
||||
assert.ok(
|
||||
result.includes('$HOME/.gemini/antigravity'),
|
||||
`Expected $HOME/.gemini/antigravity in output, got: ${result}`
|
||||
);
|
||||
assert.ok(
|
||||
!result.includes('$HOME/.claude'),
|
||||
`Expected $HOME/.claude to be replaced, got: ${result}`
|
||||
);
|
||||
});
|
||||
|
||||
test('handles bare ~/.claude followed by comma (comment context)', () => {
|
||||
const input = '# e.g. ~/.claude, ~/.config/opencode';
|
||||
const result = convertClaudeToAntigravityContent(input, true);
|
||||
assert.ok(
|
||||
!result.includes('~/.claude'),
|
||||
`Expected ~/ .claude to be replaced in comment context, got: ${result}`
|
||||
);
|
||||
});
|
||||
|
||||
test('still replaces ~/.claude/ (with trailing slash) correctly', () => {
|
||||
const input = 'See ~/.claude/gsd-core/workflows/';
|
||||
const result = convertClaudeToAntigravityContent(input, true);
|
||||
assert.ok(
|
||||
result.includes('~/.gemini/antigravity/gsd-core/workflows/'),
|
||||
`Expected path with trailing slash to be replaced, got: ${result}`
|
||||
);
|
||||
assert.ok(!result.includes('~/.claude/'), `Expected ~/ .claude/ to be fully replaced, got: ${result}`);
|
||||
});
|
||||
|
||||
test('does not double-replace ~/.claude/ paths', () => {
|
||||
const input = 'See ~/.claude/gsd-core/';
|
||||
const result = convertClaudeToAntigravityContent(input, true);
|
||||
// Result should contain exactly one occurrence of the replacement path
|
||||
const count = (result.match(/~\/.gemini\/antigravity\//g) || []).length;
|
||||
assert.strictEqual(count, 1, `Expected exactly 1 replacement, got ${count} in: ${result}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('local install', () => {
|
||||
test('replaces ~/.claude (bare, no trailing slash) with .agents', () => {
|
||||
const input = 'configDir = ~/.claude';
|
||||
const result = convertClaudeToAntigravityContent(input, false);
|
||||
assert.ok(
|
||||
result.includes('.agents'),
|
||||
`Expected .agents in output, got: ${result}`
|
||||
);
|
||||
assert.ok(
|
||||
!result.includes('~/.claude'),
|
||||
`Expected ~/ .claude to be replaced, got: ${result}`
|
||||
);
|
||||
});
|
||||
|
||||
test('replaces $HOME/.claude (bare, no trailing slash) with .agents', () => {
|
||||
const input = 'export DIR=$HOME/.claude';
|
||||
const result = convertClaudeToAntigravityContent(input, false);
|
||||
assert.ok(
|
||||
result.includes('.agents'),
|
||||
`Expected .agents in output, got: ${result}`
|
||||
);
|
||||
assert.ok(
|
||||
!result.includes('$HOME/.claude'),
|
||||
`Expected $HOME/.claude to be replaced, got: ${result}`
|
||||
);
|
||||
});
|
||||
|
||||
test('does not double-replace ~/.claude/ paths', () => {
|
||||
const input = 'See ~/.claude/gsd-core/';
|
||||
const result = convertClaudeToAntigravityContent(input, false);
|
||||
// .agents/ should appear exactly once
|
||||
const count = (result.match(/\.agents\//g) || []).length;
|
||||
assert.strictEqual(count, 1, `Expected exactly 1 replacement, got ${count} in: ${result}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('installed files contain no bare ~/.claude references after conversion', () => {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const repoRoot = path.join(__dirname, '..');
|
||||
|
||||
// The scanner regex used by the installer to detect leaked paths
|
||||
const leakedPathRegex = /(?:~|\$HOME)\/\.claude\b/g;
|
||||
|
||||
function convertFile(filePath, isGlobal) {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
return convertClaudeToAntigravityContent(content, isGlobal);
|
||||
}
|
||||
|
||||
test('gsd-debugger.md has no leaked ~/.claude after global Antigravity conversion', () => {
|
||||
const debuggerPath = path.join(repoRoot, 'agents', 'gsd-debugger.md');
|
||||
if (!fs.existsSync(debuggerPath)) return; // skip if file doesn't exist
|
||||
const converted = convertFile(debuggerPath, true);
|
||||
const matches = converted.match(leakedPathRegex);
|
||||
assert.strictEqual(
|
||||
matches, null,
|
||||
`gsd-debugger.md still contains leaked .claude paths after Antigravity conversion: ${matches}`
|
||||
);
|
||||
});
|
||||
|
||||
test('update.md has no leaked ~/.claude after global Antigravity conversion', () => {
|
||||
const updatePath = path.join(repoRoot, 'gsd-core', 'workflows', 'update.md');
|
||||
if (!fs.existsSync(updatePath)) return; // skip if file doesn't exist
|
||||
const converted = convertFile(updatePath, true);
|
||||
const matches = converted.match(leakedPathRegex);
|
||||
assert.strictEqual(
|
||||
matches, null,
|
||||
`update.md still contains leaked .claude paths after Antigravity conversion: ${matches}`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* Regression test for issue #2545.
|
||||
*
|
||||
* The Copilot content converter's `~/.claude/` and `$HOME/.claude/` replacements
|
||||
* only matched when a literal slash followed, so bare `~/.claude` references
|
||||
* (end of line, quotes, punctuation) were left unreplaced. Those leaks then
|
||||
* triggered the installer's "Found N unreplaced .claude path reference(s)"
|
||||
* warning, which scans for `(?:~|$HOME)/\.claude\b`.
|
||||
*
|
||||
* Fix: replace with a word-boundary pattern so both forms are caught in a
|
||||
* single pass, matching the approach already used by the Antigravity, OpenCode,
|
||||
* Kilo, and Codex converters.
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { convertClaudeToCopilotContent } = require('../bin/install.js');
|
||||
|
||||
describe('convertClaudeToCopilotContent — bare ~/.claude (issue #2545)', () => {
|
||||
test('global install replaces bare ~/.claude at end of line', () => {
|
||||
const input = 'configDir = ~/.claude\n';
|
||||
const out = convertClaudeToCopilotContent(input, /* isGlobal */ true);
|
||||
assert.ok(
|
||||
!/(?:~|\$HOME)\/\.claude\b/.test(out),
|
||||
`expected no leaked ~/.claude reference, got: ${JSON.stringify(out)}`,
|
||||
);
|
||||
assert.match(out, /~\/\.copilot\b/);
|
||||
});
|
||||
|
||||
test('global install replaces bare $HOME/.claude at end of line', () => {
|
||||
const input = 'configDir = $HOME/.claude\n';
|
||||
const out = convertClaudeToCopilotContent(input, /* isGlobal */ true);
|
||||
assert.ok(
|
||||
!/(?:~|\$HOME)\/\.claude\b/.test(out),
|
||||
`expected no leaked $HOME/.claude reference, got: ${JSON.stringify(out)}`,
|
||||
);
|
||||
assert.match(out, /\$HOME\/\.copilot\b/);
|
||||
});
|
||||
|
||||
test('global install replaces bare ~/.claude before punctuation', () => {
|
||||
const input = 'paths include `~/.claude`, `~/.copilot`';
|
||||
const out = convertClaudeToCopilotContent(input, true);
|
||||
assert.ok(!/(?:~|\$HOME)\/\.claude\b/.test(out));
|
||||
});
|
||||
|
||||
test('local install replaces bare ~/.claude', () => {
|
||||
const input = 'configDir = ~/.claude\n';
|
||||
const out = convertClaudeToCopilotContent(input, /* isGlobal */ false);
|
||||
assert.ok(
|
||||
!/(?:~|\$HOME)\/\.claude\b/.test(out),
|
||||
`expected no leaked ~/.claude reference, got: ${JSON.stringify(out)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not double-replace trailing-slash form', () => {
|
||||
const input = '@~/.claude/gsd-core/foo.md\n';
|
||||
const out = convertClaudeToCopilotContent(input, true);
|
||||
assert.match(out, /~\/\.copilot\/gsd-core\/foo\.md/);
|
||||
assert.ok(!/\.copilot\/\.copilot/.test(out));
|
||||
});
|
||||
});
|
||||
@@ -1,206 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Bug #2643 / #2808: skill frontmatter name parity.
|
||||
*
|
||||
* Original (#2643): workflows emitted Skill(skill="gsd:<cmd>") and the
|
||||
* installer registered colon form in SKILL.md name: to match.
|
||||
*
|
||||
* Updated (#2808): workflows now use Skill(skill="gsd-<cmd>") (hyphen),
|
||||
* and the installer emits name: gsd-<cmd> (hyphen). Claude Code autocomplete
|
||||
* now shows the canonical hyphen form instead of the deprecated colon form.
|
||||
* The directory name (gsd-<cmd>) is unchanged.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const {
|
||||
convertClaudeCommandToClaudeSkill,
|
||||
skillFrontmatterName,
|
||||
} = require(path.join(ROOT, 'bin', 'install.js'));
|
||||
|
||||
const WORKFLOWS_DIR = path.join(ROOT, 'gsd-core', 'workflows');
|
||||
const COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd');
|
||||
|
||||
function collectFiles(dir, results) {
|
||||
if (!results) results = [];
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return results; }
|
||||
for (const e of entries) {
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) collectFiles(full, results);
|
||||
else if (e.name.endsWith('.md')) results.push(full);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract every `Skill(skill="<name>")` invocation as a structured record.
|
||||
*
|
||||
* Per project test rigor (`feedback_no_source_grep_tests.md`), this parses
|
||||
* each call as a unit instead of leaning on a single regex over raw bytes.
|
||||
* The flow is:
|
||||
*
|
||||
* 1. Strip HTML comments so commented-out examples don't count as drift.
|
||||
* 2. Walk the content for `Skill(` openers; for each, find the matching
|
||||
* `)` closer (Skill bodies are simple kwarg lists, no nesting).
|
||||
* 3. Parse the call body for the `skill = "..."` keyword argument.
|
||||
* Permissive whitespace around the keyword and `=`, permissive
|
||||
* single/double quoting (with optional `\` escapes from string-
|
||||
* embedded examples), permissive name body — so malformed drift like
|
||||
* `Skill(skill="gsd:extract_learnings")` is surfaced rather than
|
||||
* silently skipped by an over-strict character class.
|
||||
*
|
||||
* Returns `[{ name, raw }]` per call. Filtering by namespace (gsd- vs gsd:)
|
||||
* happens at the call site so the extractor stays neutral.
|
||||
*/
|
||||
function extractSkillCalls(content) {
|
||||
// regex-free HTML-comment stripper (CodeQL: avoid incomplete-multi-character-sanitization)
|
||||
let stripped = '';
|
||||
{
|
||||
let rest = content;
|
||||
let idx;
|
||||
while ((idx = rest.indexOf('<!--')) !== -1) {
|
||||
stripped += rest.slice(0, idx);
|
||||
const end = rest.indexOf('-->', idx + 4);
|
||||
if (end === -1) { rest = ''; break; }
|
||||
rest = rest.slice(end + 3);
|
||||
}
|
||||
stripped += rest;
|
||||
}
|
||||
const calls = [];
|
||||
// Body class excludes backslash so the extractor doesn't include an
|
||||
// escape character that precedes the closing quote in embedded examples
|
||||
// (e.g. `Skill(skill=\"gsd-plan-phase\", …)` written inside a string
|
||||
// context). A trailing `\` is permitted on the closing-quote side via the
|
||||
// optional `\\?` so both `\"` and `"` close the value cleanly.
|
||||
const argRe = /^\s*skill\s*=\s*\\?(['"])([^'"\\]+)\\?\1/i;
|
||||
let i = 0;
|
||||
while (i < stripped.length) {
|
||||
const open = stripped.indexOf('Skill(', i);
|
||||
if (open === -1) break;
|
||||
const close = stripped.indexOf(')', open);
|
||||
if (close === -1) break;
|
||||
const body = stripped.slice(open + 'Skill('.length, close);
|
||||
const match = body.match(argRe);
|
||||
if (match) calls.push({ name: match[2], raw: stripped.slice(open, close + 1) });
|
||||
i = close + 1;
|
||||
}
|
||||
return calls;
|
||||
}
|
||||
|
||||
function extractSkillNamesHyphen(content) {
|
||||
return new Set(
|
||||
extractSkillCalls(content)
|
||||
.map((c) => c.name)
|
||||
.filter((n) => n.startsWith('gsd-')),
|
||||
);
|
||||
}
|
||||
|
||||
function extractSkillNamesColon(content) {
|
||||
return new Set(
|
||||
extractSkillCalls(content)
|
||||
.map((c) => c.name)
|
||||
.filter((n) => n.startsWith('gsd:')),
|
||||
);
|
||||
}
|
||||
|
||||
describe('skill frontmatter name parity (#2643 / #2808)', () => {
|
||||
test('skillFrontmatterName helper emits hyphen form (#2808)', () => {
|
||||
assert.strictEqual(typeof skillFrontmatterName, 'function');
|
||||
assert.strictEqual(skillFrontmatterName('gsd-execute-phase'), 'gsd-execute-phase');
|
||||
assert.strictEqual(skillFrontmatterName('gsd-plan-phase'), 'gsd-plan-phase');
|
||||
assert.strictEqual(skillFrontmatterName('gsd-next'), 'gsd-next');
|
||||
});
|
||||
|
||||
test('convertClaudeCommandToClaudeSkill emits name: gsd-<cmd> (hyphen)', () => {
|
||||
const input = '---\nname: old\ndescription: test\n---\n\nBody.';
|
||||
const result = convertClaudeCommandToClaudeSkill(input, 'gsd-execute-phase');
|
||||
// Parse the frontmatter block structurally: extract the name: field value.
|
||||
const frontmatterMatch = result.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
assert.ok(frontmatterMatch, 'output must have a frontmatter block delimited by ---');
|
||||
const frontmatterLines = frontmatterMatch[1].split(/\r?\n/);
|
||||
const nameEntry = frontmatterLines.find((l) => l.startsWith('name:'));
|
||||
assert.ok(nameEntry, 'frontmatter must contain a name: field');
|
||||
const nameValue = nameEntry.replace(/^name:\s*/, '').trim();
|
||||
assert.strictEqual(
|
||||
nameValue,
|
||||
'gsd-execute-phase',
|
||||
`frontmatter name: must be 'gsd-execute-phase' (hyphen form), got '${nameValue}'`
|
||||
);
|
||||
});
|
||||
|
||||
test('no workflow uses deprecated Skill(skill="gsd:<cmd>") colon form', () => {
|
||||
const workflowFiles = collectFiles(WORKFLOWS_DIR);
|
||||
const colonRefs = [];
|
||||
for (const f of workflowFiles) {
|
||||
const src = fs.readFileSync(f, 'utf-8');
|
||||
for (const n of extractSkillNamesColon(src)) {
|
||||
colonRefs.push(path.basename(f) + ': ' + n);
|
||||
}
|
||||
}
|
||||
assert.deepStrictEqual(
|
||||
colonRefs,
|
||||
[],
|
||||
'deprecated colon-form Skill() calls found (update to hyphen): ' + colonRefs.join(', ')
|
||||
);
|
||||
});
|
||||
|
||||
test('every workflow Skill(skill="gsd-<cmd>") resolves to an emitted skill name', () => {
|
||||
const workflowFiles = collectFiles(WORKFLOWS_DIR);
|
||||
const referenced = new Set();
|
||||
const templatedSkipped = [];
|
||||
for (const f of workflowFiles) {
|
||||
const src = fs.readFileSync(f, 'utf-8');
|
||||
for (const n of extractSkillNamesHyphen(src)) {
|
||||
// Skip template expressions (e.g. `gsd-${ref.skill}`): these are
|
||||
// capability-dispatched — the skill stem is resolved at runtime from
|
||||
// the `loop render-hooks` registry output (ADR-857 phase 6), so there
|
||||
// is no single literal skill file to validate against here.
|
||||
// The capability registry's own validateStep gate (gen-capability-registry.cjs)
|
||||
// is responsible for ensuring each `steps[].ref.skill` corresponds to a
|
||||
// real skill declared in the capability's `skills` array.
|
||||
if (n.includes('${')) {
|
||||
templatedSkipped.push(path.basename(f) + ': ' + n);
|
||||
} else {
|
||||
referenced.add(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(
|
||||
referenced.size > 0,
|
||||
`expected at least one literal Skill(skill="gsd-<cmd>") reference in workflows under ${WORKFLOWS_DIR}`
|
||||
);
|
||||
|
||||
const emitted = new Set();
|
||||
const cmdFiles = fs.readdirSync(COMMANDS_DIR).filter(f => f.endsWith('.md'));
|
||||
for (const cmd of cmdFiles) {
|
||||
const base = cmd.replace(/\.md$/, '');
|
||||
const skillDirName = 'gsd-' + base;
|
||||
const src = fs.readFileSync(path.join(COMMANDS_DIR, cmd), 'utf-8');
|
||||
const out = convertClaudeCommandToClaudeSkill(src, skillDirName);
|
||||
const m = out.match(/^---\r?\nname:\s*(.+)$/m);
|
||||
if (m) emitted.add(m[1].trim());
|
||||
}
|
||||
|
||||
const missing = [];
|
||||
for (const r of referenced) if (!emitted.has(r)) missing.push(r);
|
||||
assert.deepStrictEqual(
|
||||
missing,
|
||||
[],
|
||||
'workflow refs not emitted as skill names: ' + missing.join(', '),
|
||||
);
|
||||
// Informational: report how many templated dispatches were intentionally skipped.
|
||||
// (Templated names are validated by the capability registry, not statically here.)
|
||||
if (templatedSkipped.length > 0) {
|
||||
// Not a failure — just a note for test output transparency.
|
||||
// Use a diagnostic comment: node:test does not have a skip-within-test API.
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,186 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Regression test for #2698: CRLF line endings break agent-block strip regexes
|
||||
*
|
||||
* The legacy `gsd-update-check` hook migration in bin/install.js uses two
|
||||
* separate .replace() calls:
|
||||
* 1. LF-only regex: /\n# GSD Hooks\n\[\[hooks\]\]\nevent = ...\n/
|
||||
* 2. CRLF-only regex: /\r\n# GSD Hooks\r\n\[\[hooks\]\]\r\nevent = ...\r\n/
|
||||
*
|
||||
* These patterns fail when config.toml has mixed line endings — e.g. the
|
||||
* "# GSD Hooks" header uses LF but the body uses CRLF, or vice versa. This
|
||||
* can happen when the file is created cross-platform (Windows/Linux), when
|
||||
* editors convert only part of the file, or when a previous GSD version wrote
|
||||
* the block with different EOL than the file's dominant EOL.
|
||||
*
|
||||
* Fix: consolidate to a single \r?\n-aware regex that handles LF, CRLF, and
|
||||
* any mix in a single pass, making the migration robust regardless of the
|
||||
* platform the file was last written on.
|
||||
*
|
||||
* Test approach: write a `.codex/config.toml` with a stale gsd-update-check
|
||||
* block that uses mixed line endings (header in LF, body in CRLF), then run
|
||||
* install() and assert the stale block is gone.
|
||||
*
|
||||
* Note: The local Codex install writes to `.codex/` in the current directory.
|
||||
* Tests `process.chdir(tmpDir)` and write fixtures to `tmpDir/.codex/`.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test, before, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
||||
const { install, GSD_CODEX_MARKER } = require(INSTALL_SRC);
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
// Ensure hooks/dist/ is populated before install tests
|
||||
before(() => {
|
||||
execFileSync(process.execPath, [BUILD_SCRIPT], {
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2698: CRLF stale gsd-update-check block is removed on Codex reinstall', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-crlf-install-2698-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Use the shared 5s Windows-EBUSY retry budget instead of inline 1s.
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
// Helper: pre-populate .codex/config.toml with a GSD marker + stale hooks block
|
||||
// using the given line ending for the stale hooks block header, and a potentially
|
||||
// different EOL for the hooks body. This exercises the cross-platform mixed scenario.
|
||||
function writeCodexConfigWithStaleHooks(dir, headerEol, bodyEol) {
|
||||
// Build the stale block with header EOL for the "# GSD Hooks" line, but body EOL
|
||||
// for the content lines (simulates a file edited by two different platforms).
|
||||
const staleBlock = [
|
||||
'# GSD Hooks', // line that starts the stale section
|
||||
'[[hooks]]',
|
||||
'event = "SessionStart"',
|
||||
'command = "node /old/path/gsd-update-check.js"',
|
||||
].join(bodyEol);
|
||||
|
||||
// Put the stale block in user content BEFORE the GSD marker. The GSD marker area
|
||||
// will be regenerated by mergeCodexConfig during install(); the stale block in
|
||||
// the user area is what the hooks migration must remove.
|
||||
const content = [
|
||||
'[features]',
|
||||
'codex_hooks = true',
|
||||
'',
|
||||
].join(headerEol) + headerEol + staleBlock + headerEol + headerEol + GSD_CODEX_MARKER + headerEol;
|
||||
|
||||
const codexDir = path.join(dir, '.codex');
|
||||
fs.mkdirSync(codexDir, { recursive: true });
|
||||
const configPath = path.join(codexDir, 'config.toml');
|
||||
fs.writeFileSync(configPath, content, 'utf-8');
|
||||
return configPath;
|
||||
}
|
||||
|
||||
function readHooksSessionStartCommands(codexHome) {
|
||||
const hooksPath = path.join(codexHome, 'hooks.json');
|
||||
if (!fs.existsSync(hooksPath)) return [];
|
||||
const raw = fs.readFileSync(hooksPath, 'utf8').trim();
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
const table = (parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks))
|
||||
? parsed.hooks
|
||||
: parsed;
|
||||
const sessionStart = Array.isArray(table.SessionStart) ? table.SessionStart : [];
|
||||
return sessionStart.flatMap((entry) => [
|
||||
...(typeof entry?.command === 'string' ? [entry.command] : []),
|
||||
...(Array.isArray(entry?.hooks)
|
||||
? entry.hooks.map((hook) => hook && hook.command).filter((cmd) => typeof cmd === 'string')
|
||||
: []),
|
||||
]);
|
||||
}
|
||||
|
||||
test('LF config.toml: stale gsd-update-check block removed on reinstall', (t) => {
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
process.chdir(tmpDir);
|
||||
|
||||
writeCodexConfigWithStaleHooks(tmpDir, '\n', '\n');
|
||||
install(false, 'codex');
|
||||
|
||||
const configPath = path.join(tmpDir, '.codex', 'config.toml');
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
|
||||
assert.ok(
|
||||
!content.includes('gsd-update-check'),
|
||||
'Stale gsd-update-check entry must be removed from LF config.toml (#2698)'
|
||||
);
|
||||
const hooksJsonCommands = readHooksSessionStartCommands(path.join(tmpDir, '.codex'));
|
||||
assert.equal(
|
||||
hooksJsonCommands.some((cmd) => cmd.includes('gsd-check-update')),
|
||||
true,
|
||||
'New gsd-check-update hook must appear in hooks.json after reinstall'
|
||||
);
|
||||
});
|
||||
|
||||
test('CRLF config.toml: stale gsd-update-check block removed on reinstall', (t) => {
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
process.chdir(tmpDir);
|
||||
|
||||
writeCodexConfigWithStaleHooks(tmpDir, '\r\n', '\r\n');
|
||||
install(false, 'codex');
|
||||
|
||||
const configPath = path.join(tmpDir, '.codex', 'config.toml');
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
|
||||
assert.ok(
|
||||
!content.includes('gsd-update-check'),
|
||||
'Stale gsd-update-check entry must be removed from CRLF config.toml (#2698)'
|
||||
);
|
||||
const hooksJsonCommands = readHooksSessionStartCommands(path.join(tmpDir, '.codex'));
|
||||
assert.equal(
|
||||
hooksJsonCommands.some((cmd) => cmd.includes('gsd-check-update')),
|
||||
true,
|
||||
'New gsd-check-update hook must appear in hooks.json after reinstall'
|
||||
);
|
||||
});
|
||||
|
||||
test('mixed-EOL config.toml: stale block with LF header but CRLF body removed on reinstall', (t) => {
|
||||
// This is the primary failure case: header line uses LF but the body uses CRLF.
|
||||
// The old LF-only regex requires all-\n separators; the old CRLF-only regex requires
|
||||
// all-\r\n separators. Neither matches a block with mixed endings, so the stale
|
||||
// block survives reinstall with the old code (#2698).
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
process.chdir(tmpDir);
|
||||
|
||||
// headerEol='\n' (file dominant), bodyEol='\r\n' (hook block from another platform)
|
||||
writeCodexConfigWithStaleHooks(tmpDir, '\n', '\r\n');
|
||||
install(false, 'codex');
|
||||
|
||||
const configPath = path.join(tmpDir, '.codex', 'config.toml');
|
||||
const content = fs.readFileSync(configPath, 'utf-8');
|
||||
|
||||
assert.ok(
|
||||
!content.includes('gsd-update-check'),
|
||||
[
|
||||
'Stale gsd-update-check block with mixed LF/CRLF endings must be removed (#2698).',
|
||||
'Old code used two separate LF-only and CRLF-only regexes; neither matched mixed content.',
|
||||
'Fix consolidates to a single \\r?\\n-aware regex.',
|
||||
].join(' ')
|
||||
);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,306 +0,0 @@
|
||||
/**
|
||||
* Regression tests for bug #2771: USER-PROFILE.md tracked in install manifest
|
||||
*
|
||||
* USER-PROFILE.md is a user-owned artifact created/refreshed by /gsd-profile-user.
|
||||
* preserveUserArtifacts() correctly preserves it across reinstalls. But writeManifest()
|
||||
* also records it under "gsd-core/USER-PROFILE.md" with a SHA-256 of whatever was
|
||||
* on disk at install time. On the next install, saveLocalPatches() compares the on-disk
|
||||
* (refreshed) hash to the manifest hash, finds them different, and emits the spurious
|
||||
* "Found N locally modified GSD file(s) — backed up to gsd-local-patches/" warning.
|
||||
*
|
||||
* Invariant: a file is either distribution (manifest-tracked, diff'd against manifest)
|
||||
* or user artifact (preserved across installs, never diff'd). It cannot be both. The
|
||||
* shared truth source must be a single USER_OWNED_ARTIFACTS list referenced by both
|
||||
* preserveUserArtifacts callers and writeManifest.
|
||||
*
|
||||
* Closes: #2771
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, beforeEach, afterEach, before } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { execFileSync } = require('child_process');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
||||
const MANIFEST_NAME = 'gsd-file-manifest.json';
|
||||
const PATCHES_DIR_NAME = 'gsd-local-patches';
|
||||
|
||||
before(() => {
|
||||
execFileSync(process.execPath, [BUILD_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' });
|
||||
});
|
||||
|
||||
function runInstaller(configDir) {
|
||||
const env = { ...process.env, CLAUDE_CONFIG_DIR: configDir };
|
||||
delete env.GSD_TEST_MODE;
|
||||
return execFileSync(
|
||||
process.execPath,
|
||||
[INSTALL_SCRIPT, '--claude', '--global', '--yes', '--no-sdk'],
|
||||
{ encoding: 'utf-8', stdio: 'pipe', env }
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Test 1: writeManifest must NOT record USER-PROFILE.md ────────────────────
|
||||
|
||||
describe('#2771: USER-PROFILE.md is excluded from gsd-file-manifest.json', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => { tmpDir = createTempDir('gsd-2771-manifest-'); });
|
||||
afterEach(() => { cleanup(tmpDir); });
|
||||
|
||||
test('writeManifest excludes gsd-core/USER-PROFILE.md even when present on disk', () => {
|
||||
runInstaller(tmpDir);
|
||||
|
||||
// Simulate /gsd-profile-user creating USER-PROFILE.md
|
||||
const profilePath = path.join(tmpDir, 'gsd-core', 'USER-PROFILE.md');
|
||||
fs.writeFileSync(profilePath, '# My Profile\n\nFirst version.\n');
|
||||
|
||||
// Re-install: writeManifest runs again with USER-PROFILE.md present on disk
|
||||
runInstaller(tmpDir);
|
||||
|
||||
const manifestPath = path.join(tmpDir, MANIFEST_NAME);
|
||||
assert.ok(fs.existsSync(manifestPath), 'manifest must be written');
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
|
||||
assert.ok(
|
||||
!Object.prototype.hasOwnProperty.call(manifest.files, 'gsd-core/USER-PROFILE.md'),
|
||||
'manifest.files must NOT contain gsd-core/USER-PROFILE.md — it is a user artifact, not distribution'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test 2: preserveUserArtifacts still preserves USER-PROFILE.md ────────────
|
||||
|
||||
describe('#2771: USER-PROFILE.md is still preserved across reinstall', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => { tmpDir = createTempDir('gsd-2771-preserve-'); });
|
||||
afterEach(() => { cleanup(tmpDir); });
|
||||
|
||||
test('USER-PROFILE.md content survives reinstall (preservation regression guard)', () => {
|
||||
runInstaller(tmpDir);
|
||||
|
||||
const profilePath = path.join(tmpDir, 'gsd-core', 'USER-PROFILE.md');
|
||||
const content = '# Profile\n\nUser content from /gsd-profile-user.\n';
|
||||
fs.writeFileSync(profilePath, content);
|
||||
|
||||
runInstaller(tmpDir);
|
||||
|
||||
assert.ok(fs.existsSync(profilePath), 'USER-PROFILE.md must survive reinstall');
|
||||
assert.strictEqual(fs.readFileSync(profilePath, 'utf8'), content);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test 3: no spurious "local patches" hit for USER-PROFILE.md refresh ──────
|
||||
|
||||
describe('#2771: refreshed USER-PROFILE.md does not trigger local-patches warning', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => { tmpDir = createTempDir('gsd-2771-patches-'); });
|
||||
afterEach(() => { cleanup(tmpDir); });
|
||||
|
||||
test('saveLocalPatches does not classify a refreshed USER-PROFILE.md as a local patch', () => {
|
||||
// Initial install
|
||||
runInstaller(tmpDir);
|
||||
|
||||
// /gsd-profile-user creates USER-PROFILE.md (v1)
|
||||
const profilePath = path.join(tmpDir, 'gsd-core', 'USER-PROFILE.md');
|
||||
fs.writeFileSync(profilePath, '# Profile v1\n');
|
||||
|
||||
// Reinstall — manifest written with v1 contents (under buggy code) or excluded (under fix)
|
||||
runInstaller(tmpDir);
|
||||
|
||||
// /gsd-profile-user --refresh rewrites USER-PROFILE.md (v2 != v1)
|
||||
fs.writeFileSync(profilePath, '# Profile v2 — refreshed\n');
|
||||
|
||||
// Reinstall — saveLocalPatches scans manifest. Under bug, v2 hash != v1 manifest
|
||||
// hash → patch detected. Under fix, file is not in manifest → no patch.
|
||||
const output = runInstaller(tmpDir);
|
||||
|
||||
const patchesDir = path.join(tmpDir, PATCHES_DIR_NAME);
|
||||
const patchFile = path.join(patchesDir, 'gsd-core', 'USER-PROFILE.md');
|
||||
assert.ok(
|
||||
!fs.existsSync(patchFile),
|
||||
'USER-PROFILE.md must NOT appear in gsd-local-patches/ — it is a user artifact, not a modified distribution file'
|
||||
);
|
||||
|
||||
const offendingLine = output
|
||||
.split('\n')
|
||||
.find((line) => /locally modified GSD file/.test(line) && /USER-PROFILE/.test(line));
|
||||
assert.strictEqual(
|
||||
offendingLine,
|
||||
undefined,
|
||||
'installer output must not report USER-PROFILE.md as a locally modified GSD file on any single line. Output was:\n' + output
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test 5: legacy manifest with USER-PROFILE.md entry is normalized ─────────
|
||||
|
||||
describe('#2771: legacy manifest entries for USER_OWNED_ARTIFACTS are normalized', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => { tmpDir = createTempDir('gsd-2771-legacy-'); });
|
||||
afterEach(() => { cleanup(tmpDir); });
|
||||
|
||||
test('pre-existing manifest entry for USER-PROFILE.md does not trigger patches warning', () => {
|
||||
// Initial install
|
||||
runInstaller(tmpDir);
|
||||
|
||||
const profilePath = path.join(tmpDir, 'gsd-core', 'USER-PROFILE.md');
|
||||
fs.writeFileSync(profilePath, '# Profile v1\n');
|
||||
|
||||
// Reinstall to populate manifest under the (now-fixed) writer
|
||||
runInstaller(tmpDir);
|
||||
|
||||
// Inject a stale manifest entry simulating a pre-#2771 install: a hash for
|
||||
// USER-PROFILE.md that does NOT match current content.
|
||||
const manifestPath = path.join(tmpDir, MANIFEST_NAME);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
manifest.files = manifest.files || {};
|
||||
manifest.files['gsd-core/USER-PROFILE.md'] = 'deadbeef'.repeat(8); // stale hash
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
||||
|
||||
// /gsd-profile-user --refresh rewrites USER-PROFILE.md
|
||||
fs.writeFileSync(profilePath, '# Profile v2 — refreshed\n');
|
||||
|
||||
// Reinstall — saveLocalPatches must strip the legacy entry before scanning
|
||||
const output = runInstaller(tmpDir);
|
||||
|
||||
const patchesDir = path.join(tmpDir, PATCHES_DIR_NAME);
|
||||
const patchFile = path.join(patchesDir, 'gsd-core', 'USER-PROFILE.md');
|
||||
assert.ok(
|
||||
!fs.existsSync(patchFile),
|
||||
'legacy USER-PROFILE.md manifest entry must be normalized away — not backed up as a patch'
|
||||
);
|
||||
|
||||
const offendingLine = output
|
||||
.split('\n')
|
||||
.find((line) => /locally modified GSD file/.test(line) && /USER-PROFILE/.test(line));
|
||||
assert.strictEqual(
|
||||
offendingLine,
|
||||
undefined,
|
||||
'legacy manifest entry must not surface a USER-PROFILE.md patches warning. Output was:\n' + output
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test 4: shared constant exists and is used by both call sites ────────────
|
||||
|
||||
describe('#2771: USER_OWNED_ARTIFACTS is a single source of truth', () => {
|
||||
test('install.js exports USER_OWNED_ARTIFACTS containing USER-PROFILE.md', () => {
|
||||
const origMode = process.env.GSD_TEST_MODE;
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
let mod;
|
||||
try {
|
||||
delete require.cache[require.resolve(INSTALL_SCRIPT)];
|
||||
mod = require(INSTALL_SCRIPT);
|
||||
} finally {
|
||||
if (origMode === undefined) delete process.env.GSD_TEST_MODE;
|
||||
else process.env.GSD_TEST_MODE = origMode;
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
Array.isArray(mod.USER_OWNED_ARTIFACTS) || mod.USER_OWNED_ARTIFACTS instanceof Set,
|
||||
'install.js must export USER_OWNED_ARTIFACTS as a single source of truth'
|
||||
);
|
||||
const list = Array.isArray(mod.USER_OWNED_ARTIFACTS)
|
||||
? mod.USER_OWNED_ARTIFACTS
|
||||
: Array.from(mod.USER_OWNED_ARTIFACTS);
|
||||
assert.ok(
|
||||
list.includes('USER-PROFILE.md'),
|
||||
'USER_OWNED_ARTIFACTS must include USER-PROFILE.md'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('manifest path safety', () => {
|
||||
let tmpDir;
|
||||
let outside;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-manifest-path-safety-');
|
||||
outside = path.join(tmpDir, '..', `outside-managed-file-${path.basename(tmpDir)}.txt`);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup(outside);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('saveLocalPatches ignores manifest entries that escape the install root', () => {
|
||||
const origMode = process.env.GSD_TEST_MODE;
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
let mod;
|
||||
try {
|
||||
delete require.cache[require.resolve(INSTALL_SCRIPT)];
|
||||
mod = require(INSTALL_SCRIPT);
|
||||
} finally {
|
||||
if (origMode === undefined) delete process.env.GSD_TEST_MODE;
|
||||
else process.env.GSD_TEST_MODE = origMode;
|
||||
}
|
||||
|
||||
fs.writeFileSync(outside, 'outside user data\n', 'utf8');
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, MANIFEST_NAME),
|
||||
JSON.stringify({
|
||||
version: 'legacy',
|
||||
timestamp: '2026-05-11T00:00:00.000Z',
|
||||
files: {
|
||||
'../outside-managed-file.txt': 'deadbeef',
|
||||
},
|
||||
}, null, 2),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const modified = mod.saveLocalPatches(tmpDir);
|
||||
|
||||
assert.deepEqual(modified, []);
|
||||
assert.equal(fs.readFileSync(outside, 'utf8'), 'outside user data\n');
|
||||
assert.equal(fs.existsSync(path.join(tmpDir, PATCHES_DIR_NAME, '..', path.basename(outside))), false);
|
||||
});
|
||||
|
||||
test('saveLocalPatches does not follow symlinked patch directories outside the install root', () => {
|
||||
const origMode = process.env.GSD_TEST_MODE;
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
let mod;
|
||||
try {
|
||||
delete require.cache[require.resolve(INSTALL_SCRIPT)];
|
||||
mod = require(INSTALL_SCRIPT);
|
||||
} finally {
|
||||
if (origMode === undefined) delete process.env.GSD_TEST_MODE;
|
||||
else process.env.GSD_TEST_MODE = origMode;
|
||||
}
|
||||
|
||||
const hookPath = path.join(tmpDir, 'hooks', 'managed.js');
|
||||
fs.mkdirSync(path.dirname(hookPath), { recursive: true });
|
||||
fs.writeFileSync(hookPath, 'user edited hook\n', 'utf8');
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, MANIFEST_NAME),
|
||||
JSON.stringify({
|
||||
version: 'legacy',
|
||||
timestamp: '2026-05-11T00:00:00.000Z',
|
||||
files: {
|
||||
'hooks/managed.js': crypto.createHash('sha256').update('managed hook\n').digest('hex'),
|
||||
},
|
||||
}, null, 2),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
fs.mkdirSync(outside, { recursive: true });
|
||||
try {
|
||||
fs.symlinkSync(outside, path.join(tmpDir, PATCHES_DIR_NAME), 'dir');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const modified = mod.saveLocalPatches(tmpDir);
|
||||
|
||||
assert.deepEqual(modified, []);
|
||||
assert.equal(fs.existsSync(path.join(outside, 'hooks', 'managed.js')), false);
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
'use strict';
|
||||
// allow-test-rule: source-text-is-the-product [adapter header contract in bin/install.js]
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const INSTALL_JS = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
const src = fs.readFileSync(INSTALL_JS, 'utf8');
|
||||
|
||||
describe('bug #279: Codex adapter documents Agent() and deferred tool discovery', () => {
|
||||
test('adapter mapping section includes explicit Agent(...) -> spawn_agent mapping', () => {
|
||||
assert.ok(
|
||||
/Task\(subagent_type="X", prompt="Y"\).*spawn_agent\(agent_type="X", message="Y"\)/.test(src) &&
|
||||
/Agent\(subagent_type="X", prompt="Y"\).*spawn_agent\(agent_type="X", message="Y"\)/.test(src),
|
||||
'Codex adapter must explicitly map both Task(...) and Agent(...) to spawn_agent',
|
||||
);
|
||||
});
|
||||
|
||||
test('adapter includes deferred tool_search discovery guidance before inline fallback', () => {
|
||||
assert.ok(
|
||||
src.includes('deferred') && src.includes('tool_search') && src.includes('spawn_agent'),
|
||||
'Codex adapter must instruct deferred tool discovery via tool_search before deciding to run inline',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,245 +0,0 @@
|
||||
/**
|
||||
* Regression test for bug #2794
|
||||
*
|
||||
* OpenCode generated agents ignored `model_profile_overrides.opencode.*`.
|
||||
* The agent install path called `readGsdEffectiveModelOverrides` (explicit
|
||||
* per-agent overrides) but never called `readGsdRuntimeProfileResolver`
|
||||
* (tier-based profile overrides). When a user configured:
|
||||
*
|
||||
* { runtime: "opencode", model_profile_overrides: { opencode: { sonnet: "..." } } }
|
||||
*
|
||||
* generated `.opencode/agents/gsd-*.md` files contained no `model:` frontmatter.
|
||||
*
|
||||
* The fix adds a tier-resolver fallback in the OpenCode agent conversion block:
|
||||
* explicit `model_overrides[agent]` > `model_profile_overrides.opencode.<tier>` > omit.
|
||||
*
|
||||
* This test exercises:
|
||||
* 1. `readGsdRuntimeProfileResolver` correctly resolves OpenCode tier overrides.
|
||||
* 2. The agent install code path embeds the resolved model into OpenCode frontmatter.
|
||||
* 3. Explicit `model_overrides` still wins over tier-based resolution.
|
||||
* 4. Missing overrides produce no `model:` field (no regression on omit behavior).
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const {
|
||||
readGsdRuntimeProfileResolver,
|
||||
install,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
const makeTmp = (prefix) => createTempDir(`gsd-2794-${prefix}-`);
|
||||
|
||||
function writeJson(p, obj) {
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, JSON.stringify(obj, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
|
||||
describe('bug-2794: readGsdRuntimeProfileResolver resolves opencode tier overrides', () => {
|
||||
let projectDir;
|
||||
let homeDir;
|
||||
let origHome;
|
||||
let origUP;
|
||||
|
||||
beforeEach(() => {
|
||||
projectDir = makeTmp('proj');
|
||||
homeDir = makeTmp('home');
|
||||
origHome = process.env.HOME;
|
||||
origUP = process.env.USERPROFILE;
|
||||
process.env.HOME = homeDir;
|
||||
process.env.USERPROFILE = homeDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (origHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = origHome;
|
||||
if (origUP === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = origUP;
|
||||
cleanup(projectDir);
|
||||
cleanup(homeDir);
|
||||
});
|
||||
|
||||
test('resolves opencode sonnet tier to user-supplied model ID', () => {
|
||||
writeJson(path.join(projectDir, '.planning', 'config.json'), {
|
||||
runtime: 'opencode',
|
||||
model_profile: 'balanced',
|
||||
model_profile_overrides: {
|
||||
opencode: {
|
||||
sonnet: 'anthropic/claude-sonnet-4-7',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const resolver = readGsdRuntimeProfileResolver(projectDir);
|
||||
assert.ok(resolver !== null, 'expected a resolver for opencode runtime');
|
||||
|
||||
// gsd-roadmapper balanced tier = sonnet — should resolve to override
|
||||
const entry = resolver.resolve('gsd-roadmapper');
|
||||
assert.ok(entry !== null, 'expected entry for gsd-roadmapper');
|
||||
assert.strictEqual(entry.model, 'anthropic/claude-sonnet-4-7', 'sonnet override applied');
|
||||
});
|
||||
|
||||
test('returns null resolver when runtime is not set', () => {
|
||||
writeJson(path.join(projectDir, '.planning', 'config.json'), {
|
||||
model_profile: 'balanced',
|
||||
model_profile_overrides: { opencode: { sonnet: 'x' } },
|
||||
});
|
||||
const resolver = readGsdRuntimeProfileResolver(projectDir);
|
||||
assert.strictEqual(resolver, null, 'no resolver without runtime field');
|
||||
});
|
||||
|
||||
test('resolver returns null for agent not in MODEL_PROFILES', () => {
|
||||
writeJson(path.join(projectDir, '.planning', 'config.json'), {
|
||||
runtime: 'opencode',
|
||||
model_profile: 'balanced',
|
||||
model_profile_overrides: { opencode: { sonnet: 'x' } },
|
||||
});
|
||||
const resolver = readGsdRuntimeProfileResolver(projectDir);
|
||||
assert.ok(resolver !== null);
|
||||
const entry = resolver.resolve('gsd-nonexistent-agent');
|
||||
assert.strictEqual(entry, null, 'unknown agent name yields null');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bug-2794: OpenCode agent install embeds model_profile_overrides model', () => {
|
||||
let projectDir;
|
||||
let homeDir;
|
||||
let origHome;
|
||||
let origUP;
|
||||
let origCwd;
|
||||
|
||||
beforeEach(() => {
|
||||
projectDir = makeTmp('proj');
|
||||
homeDir = makeTmp('home');
|
||||
origHome = process.env.HOME;
|
||||
origUP = process.env.USERPROFILE;
|
||||
origCwd = process.cwd();
|
||||
process.env.HOME = homeDir;
|
||||
process.env.USERPROFILE = homeDir;
|
||||
process.chdir(projectDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (origHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = origHome;
|
||||
if (origUP === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = origUP;
|
||||
process.chdir(origCwd);
|
||||
cleanup(projectDir);
|
||||
cleanup(homeDir);
|
||||
});
|
||||
|
||||
test('generated OpenCode agent frontmatter includes model from model_profile_overrides', () => {
|
||||
writeJson(path.join(projectDir, '.planning', 'config.json'), {
|
||||
runtime: 'opencode',
|
||||
model_profile: 'balanced',
|
||||
model_profile_overrides: {
|
||||
opencode: {
|
||||
sonnet: 'anthropic/claude-sonnet-4-7',
|
||||
opus: 'anthropic/claude-opus-4-7',
|
||||
haiku: 'anthropic/claude-haiku-4-5',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const oldLog = console.log;
|
||||
console.log = () => {};
|
||||
try {
|
||||
install(false, 'opencode');
|
||||
} finally {
|
||||
console.log = oldLog;
|
||||
}
|
||||
|
||||
const agentsDir = path.join(projectDir, '.opencode', 'agents');
|
||||
assert.ok(fs.existsSync(agentsDir), 'agents directory should be created');
|
||||
|
||||
// gsd-roadmapper is balanced -> sonnet tier
|
||||
const roadmapperPath = path.join(agentsDir, 'gsd-roadmapper.md');
|
||||
assert.ok(fs.existsSync(roadmapperPath), 'gsd-roadmapper.md should exist');
|
||||
const roadmapperContent = fs.readFileSync(roadmapperPath, 'utf-8');
|
||||
assert.match(
|
||||
roadmapperContent,
|
||||
/^model: anthropic\/claude-sonnet-4-7$/m,
|
||||
'gsd-roadmapper should have sonnet model from model_profile_overrides'
|
||||
);
|
||||
|
||||
// gsd-planner is balanced -> opus tier
|
||||
const plannerPath = path.join(agentsDir, 'gsd-planner.md');
|
||||
assert.ok(fs.existsSync(plannerPath), 'gsd-planner.md should exist');
|
||||
const plannerContent = fs.readFileSync(plannerPath, 'utf-8');
|
||||
assert.match(
|
||||
plannerContent,
|
||||
/^model: anthropic\/claude-opus-4-7$/m,
|
||||
'gsd-planner should have opus model from model_profile_overrides'
|
||||
);
|
||||
});
|
||||
|
||||
test('explicit model_overrides[agent] wins over model_profile_overrides tier', () => {
|
||||
writeJson(path.join(projectDir, '.planning', 'config.json'), {
|
||||
runtime: 'opencode',
|
||||
model_profile: 'balanced',
|
||||
model_overrides: {
|
||||
'gsd-roadmapper': 'explicit-winner-model',
|
||||
},
|
||||
model_profile_overrides: {
|
||||
opencode: {
|
||||
sonnet: 'tier-model-that-should-lose',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const oldLog = console.log;
|
||||
console.log = () => {};
|
||||
try {
|
||||
install(false, 'opencode');
|
||||
} finally {
|
||||
console.log = oldLog;
|
||||
}
|
||||
|
||||
const roadmapperPath = path.join(projectDir, '.opencode', 'agents', 'gsd-roadmapper.md');
|
||||
assert.ok(fs.existsSync(roadmapperPath));
|
||||
const content = fs.readFileSync(roadmapperPath, 'utf-8');
|
||||
assert.match(
|
||||
content,
|
||||
/^model: explicit-winner-model$/m,
|
||||
'explicit model_overrides must win over model_profile_overrides tier'
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
content,
|
||||
/tier-model-that-should-lose/,
|
||||
'tier model must not appear when explicit override is present'
|
||||
);
|
||||
});
|
||||
|
||||
test('no model field when neither model_overrides nor model_profile_overrides is set', () => {
|
||||
writeJson(path.join(projectDir, '.planning', 'config.json'), {
|
||||
runtime: 'opencode',
|
||||
model_profile: 'balanced',
|
||||
});
|
||||
|
||||
const oldLog = console.log;
|
||||
console.log = () => {};
|
||||
try {
|
||||
install(false, 'opencode');
|
||||
} finally {
|
||||
console.log = oldLog;
|
||||
}
|
||||
|
||||
const roadmapperPath = path.join(projectDir, '.opencode', 'agents', 'gsd-roadmapper.md');
|
||||
if (fs.existsSync(roadmapperPath)) {
|
||||
const content = fs.readFileSync(roadmapperPath, 'utf-8');
|
||||
// When no overrides, model field should either be absent or use built-in default
|
||||
// The key invariant: no model field if there are no user-supplied overrides
|
||||
// AND no built-in opencode defaults for this tier
|
||||
// (gsd-roadmapper balanced = sonnet; opencode has built-in sonnet defaults)
|
||||
// So we only assert no crash and no tier-model-not-provided entries
|
||||
assert.ok(typeof content === 'string', 'agent file should be a string');
|
||||
}
|
||||
// Key: no exception thrown (test passes = no crash on missing overrides)
|
||||
});
|
||||
});
|
||||
@@ -1,155 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Regression test for #2831: OpenCode @file references contain literal `$HOME`
|
||||
* which OpenCode does not expand — `@$HOME/.config/opencode/...` is resolved
|
||||
* as a path relative to the config command/ dir, producing
|
||||
* `command/$HOME/.config/opencode/...` (file not found).
|
||||
*
|
||||
* Root cause: install.js pathPrefix used `$HOME`-relative paths for OpenCode on
|
||||
* non-Windows hosts (only Windows was guarded by #2376). OpenCode's `@file`
|
||||
* include syntax does NOT shell-expand `$HOME` on any platform.
|
||||
*
|
||||
* Fix: pathPrefix must use the absolute path for OpenCode on all platforms.
|
||||
*
|
||||
* Tests exercise install.js's exported `computePathPrefix` directly (no source
|
||||
* grepping) and additionally simulate the `copyFlattenedCommands` substitution
|
||||
* pipeline on a temp tree to verify no `$HOME` literal leaks into emitted files.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
let computePathPrefix;
|
||||
|
||||
before(() => {
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
delete require.cache[require.resolve('../bin/install.js')];
|
||||
({ computePathPrefix } = require('../bin/install.js'));
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.GSD_TEST_MODE;
|
||||
});
|
||||
|
||||
describe('bug-2831: OpenCode pathPrefix uses absolute path on all platforms', () => {
|
||||
test('computePathPrefix is exported by install.js', () => {
|
||||
assert.equal(typeof computePathPrefix, 'function');
|
||||
});
|
||||
|
||||
test('OpenCode on macOS: pathPrefix is absolute (no $HOME)', () => {
|
||||
const pathPrefix = computePathPrefix({
|
||||
isGlobal: true,
|
||||
isOpencode: true,
|
||||
isWindowsHost: false,
|
||||
homeDir: '/Users/alice',
|
||||
resolvedTarget: '/Users/alice/.config/opencode',
|
||||
});
|
||||
assert.strictEqual(pathPrefix, '/Users/alice/.config/opencode/');
|
||||
assert.ok(!pathPrefix.includes('$HOME'));
|
||||
});
|
||||
|
||||
test('OpenCode on Linux: pathPrefix is absolute (no $HOME)', () => {
|
||||
const pathPrefix = computePathPrefix({
|
||||
isGlobal: true,
|
||||
isOpencode: true,
|
||||
isWindowsHost: false,
|
||||
homeDir: '/home/bob',
|
||||
resolvedTarget: '/home/bob/.config/opencode',
|
||||
});
|
||||
assert.strictEqual(pathPrefix, '/home/bob/.config/opencode/');
|
||||
assert.ok(!pathPrefix.includes('$HOME'));
|
||||
});
|
||||
|
||||
test('OpenCode on Windows: pathPrefix is absolute (preserves #2376)', () => {
|
||||
const pathPrefix = computePathPrefix({
|
||||
isGlobal: true,
|
||||
isOpencode: true,
|
||||
isWindowsHost: true,
|
||||
homeDir: 'C:/Users/carol',
|
||||
resolvedTarget: 'C:/Users/carol/.config/opencode',
|
||||
});
|
||||
assert.strictEqual(pathPrefix, 'C:/Users/carol/.config/opencode/');
|
||||
});
|
||||
|
||||
test('Claude Code on macOS: pathPrefix still uses $HOME (unaffected)', () => {
|
||||
const pathPrefix = computePathPrefix({
|
||||
isGlobal: true,
|
||||
isOpencode: false,
|
||||
isWindowsHost: false,
|
||||
homeDir: '/Users/alice',
|
||||
resolvedTarget: '/Users/alice/.claude',
|
||||
});
|
||||
assert.strictEqual(pathPrefix, '$HOME/.claude/');
|
||||
});
|
||||
|
||||
test('Local install (non-global): pathPrefix uses absolute path regardless of runtime', () => {
|
||||
const pathPrefix = computePathPrefix({
|
||||
isGlobal: false,
|
||||
isOpencode: false,
|
||||
isWindowsHost: false,
|
||||
homeDir: '/Users/alice',
|
||||
resolvedTarget: '/Users/alice/projects/foo/.claude',
|
||||
});
|
||||
assert.strictEqual(pathPrefix, '/Users/alice/projects/foo/.claude/');
|
||||
});
|
||||
|
||||
test('Substitution pipeline simulation: OpenCode emits no @$HOME literal', () => {
|
||||
// This validates the same regex substitution pipeline used by
|
||||
// copyFlattenedCommands when writing OpenCode command files. We invoke the
|
||||
// real exported computePathPrefix; the regex passes mirror the install.js
|
||||
// call sites (globalClaudeRegex / globalClaudeHomeRegex).
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2831-'));
|
||||
try {
|
||||
const srcRoot = path.join(tmp, 'src');
|
||||
const targetRoot = path.join(tmp, 'home', '.config', 'opencode');
|
||||
const srcCmdDir = path.join(srcRoot, 'commands', 'gsd');
|
||||
fs.mkdirSync(srcCmdDir, { recursive: true });
|
||||
fs.mkdirSync(targetRoot, { recursive: true });
|
||||
|
||||
const srcFile = path.join(srcCmdDir, 'autonomous.md');
|
||||
fs.writeFileSync(
|
||||
srcFile,
|
||||
'---\nname: autonomous\n---\n<execution_context>\n@~/.claude/gsd-core/workflows/autonomous.md\n@$HOME/.claude/gsd-core/references/ui-brand.md\n</execution_context>\n'
|
||||
);
|
||||
|
||||
const homeDir = path.join(tmp, 'home').replace(/\\/g, '/');
|
||||
const resolvedTarget = targetRoot.replace(/\\/g, '/');
|
||||
const pathPrefix = computePathPrefix({
|
||||
isGlobal: true,
|
||||
isOpencode: true,
|
||||
isWindowsHost: false,
|
||||
homeDir,
|
||||
resolvedTarget,
|
||||
});
|
||||
|
||||
let content = fs.readFileSync(srcFile, 'utf8');
|
||||
content = content.replace(/~\/\.claude\//g, pathPrefix);
|
||||
content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
|
||||
|
||||
assert.ok(
|
||||
!/@\$HOME\b/.test(content),
|
||||
`output must not contain @$HOME literal; got:\n${content}`
|
||||
);
|
||||
assert.ok(
|
||||
!/\$HOME\b/.test(content),
|
||||
`output must not contain $HOME literal; got:\n${content}`
|
||||
);
|
||||
assert.ok(
|
||||
content.includes(`@${resolvedTarget}/`),
|
||||
`output should include absolute path with @ prefix; got:\n${content}`
|
||||
);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Bug #2957: post-install message for `--claude --global` must instruct
|
||||
* users to restart Claude Code and offer the skill-name fallback, since
|
||||
* the skills-only install layout (CC 2.1.88+) leaves nothing in
|
||||
* commands/gsd/ for the slash menu to read on older configurations.
|
||||
*
|
||||
* Captures the call to finishInstall(runtime='claude', isGlobal=true) and
|
||||
* asserts the printed message contains both invocation paths.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const SETTINGS_PATH = path.join(os.tmpdir(), `gsd-test-settings-${process.pid}.json`);
|
||||
const installModule = require(path.join(ROOT, 'bin', 'install.js'));
|
||||
|
||||
function captureFinishInstallOutput(runtime, isGlobal) {
|
||||
const original = console.log;
|
||||
const lines = [];
|
||||
console.log = (...args) => { lines.push(args.join(' ')); };
|
||||
try {
|
||||
installModule.finishInstall(
|
||||
SETTINGS_PATH,
|
||||
{},
|
||||
null,
|
||||
false,
|
||||
runtime,
|
||||
isGlobal,
|
||||
null,
|
||||
);
|
||||
} finally {
|
||||
console.log = original;
|
||||
}
|
||||
// Strip ANSI color escapes so message-content assertions don't couple to colors.
|
||||
// eslint-disable-next-line no-control-regex -- \x1b (ESC) is the required leading byte of ANSI SGR color sequences; matching it is the purpose of stripping ANSI codes from captured CLI/console output
|
||||
return lines.join('\n').replace(/\x1B\[[0-9;]*m/g, '');
|
||||
}
|
||||
|
||||
describe('Bug #2957: claude+global post-install message', () => {
|
||||
test('claude+global message tells the user to restart and offers skill-name fallback', () => {
|
||||
const output = captureFinishInstallOutput('claude', true);
|
||||
|
||||
assert.match(output, /restart claude code/i, 'should mention restart');
|
||||
assert.match(output, /\/gsd-new-project/, 'should still mention /gsd-new-project');
|
||||
assert.match(output, /gsd-new-project skill/i, 'should mention the skill name fallback');
|
||||
assert.doesNotMatch(
|
||||
output,
|
||||
/open a blank directory/i,
|
||||
'global claude install should replace, not extend, the legacy generic instruction',
|
||||
);
|
||||
});
|
||||
|
||||
test('claude+local message keeps the original /gsd-new-project instruction', () => {
|
||||
const output = captureFinishInstallOutput('claude', false);
|
||||
|
||||
assert.match(output, /\/gsd-new-project/, 'should still mention /gsd-new-project');
|
||||
assert.doesNotMatch(output, /restart claude code/i, 'local install does not require the skills restart note');
|
||||
});
|
||||
|
||||
test('non-claude runtimes keep their original message format', () => {
|
||||
const output = captureFinishInstallOutput('opencode', true);
|
||||
|
||||
assert.match(output, /Open a blank directory/, 'opencode message should be unchanged');
|
||||
assert.doesNotMatch(output, /restart/i, 'opencode message should not have the claude-specific restart note');
|
||||
});
|
||||
});
|
||||
@@ -1,555 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Bug #2979: Managed JS hooks fail in GUI/minimal-PATH runtimes because
|
||||
* the installer emits bare `node`.
|
||||
*
|
||||
* Reporter evidence: in a stripped PATH like /usr/bin:/bin:/usr/sbin:/sbin
|
||||
* (the default for Finder-launched/Antigravity-spawned processes on macOS),
|
||||
* `node` is not resolvable. Hook commands like
|
||||
* `node "<HOME>/.gemini/hooks/gsd-check-update.js"`
|
||||
* fail with `/bin/sh: node: command not found` (exit 127).
|
||||
*
|
||||
* Fix: emit the absolute node path (`process.execPath`, the binary
|
||||
* running the installer itself) as the runner. Forward-slash-normalized
|
||||
* and double-quoted so it works on POSIX and Windows.
|
||||
*
|
||||
* This test exercises the public buildHookCommand surface plus the
|
||||
* resolveNodeRunner helper, asserting on structured records:
|
||||
* - the runner field is an absolute path (not bare 'node')
|
||||
* - it ends with /node or \\node (or .exe on Windows simulation)
|
||||
* - .sh hooks still use bare 'bash' (PATH-resolved; portable across
|
||||
* distros that don't ship /bin/bash, like NixOS)
|
||||
*
|
||||
* No source-grep on install.js content — assertions go against the
|
||||
* value returned by the exported function and the parsed structure of
|
||||
* the emitted hook command (split into runner + args).
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
|
||||
const INSTALL = require(path.join(__dirname, '..', 'bin', 'install.js'));
|
||||
const { buildHookCommand, resolveNodeRunner } = INSTALL;
|
||||
|
||||
/**
|
||||
* Parse a hook command string into { runner, hookPath } structured
|
||||
* record. The shape is `<runner> "<hookPath>"` where <runner> may itself
|
||||
* be a quoted absolute path (containing spaces), so we split on the
|
||||
* trailing quoted-path token rather than the first space.
|
||||
*/
|
||||
function parseHookCommand(cmd) {
|
||||
// Trailing token: a double-quoted string ending the command.
|
||||
const m = cmd.match(/^(.+?)\s+"([^"]+)"\s*$/);
|
||||
if (!m) {
|
||||
return { runner: null, hookPath: null, raw: cmd };
|
||||
}
|
||||
return { runner: m[1], hookPath: m[2], raw: cmd };
|
||||
}
|
||||
|
||||
describe('Bug #2979: resolveNodeRunner returns absolute, quoted, forward-slash node path', () => {
|
||||
test('exported as a function', () => {
|
||||
assert.equal(typeof resolveNodeRunner, 'function');
|
||||
});
|
||||
|
||||
test('returns a double-quoted absolute path', () => {
|
||||
const runner = resolveNodeRunner();
|
||||
assert.ok(runner.startsWith('"'), `expected leading double-quote, got: ${runner}`);
|
||||
assert.ok(runner.endsWith('"'), `expected trailing double-quote, got: ${runner}`);
|
||||
const inner = runner.slice(1, -1);
|
||||
assert.ok(path.isAbsolute(inner.replace(/\//g, path.sep)), `expected absolute path, got: ${inner}`);
|
||||
});
|
||||
|
||||
test('uses forward slashes (Windows-safe, matches buildHookCommand convention)', () => {
|
||||
const runner = resolveNodeRunner();
|
||||
assert.ok(!runner.includes('\\'), `expected forward slashes, got: ${runner}`);
|
||||
});
|
||||
|
||||
test('points at a node binary (basename starts with "node")', () => {
|
||||
const runner = resolveNodeRunner();
|
||||
const inner = runner.slice(1, -1);
|
||||
const base = path.posix.basename(inner);
|
||||
assert.ok(/^node(\.exe)?$/i.test(base), `expected basename node or node.exe, got: ${base}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bug #2979: buildHookCommand for .js hooks emits absolute node runner', () => {
|
||||
test('global install: .js hook uses absolute node path, not bare "node"', () => {
|
||||
const cmd = buildHookCommand('/tmp/.claude', 'gsd-check-update.js');
|
||||
const parsed = parseHookCommand(cmd);
|
||||
assert.notEqual(parsed.runner, null, `failed to parse: ${cmd}`);
|
||||
assert.notEqual(parsed.runner, 'node', `must not emit bare node (#2979): ${cmd}`);
|
||||
// The runner should be a quoted absolute path.
|
||||
assert.ok(parsed.runner.startsWith('"') && parsed.runner.endsWith('"'),
|
||||
`runner must be quoted absolute path, got: ${parsed.runner}`);
|
||||
});
|
||||
|
||||
test('global install: .js hook command parses with hookPath at expected location', () => {
|
||||
const cmd = buildHookCommand('/tmp/.gemini', 'gsd-statusline.js');
|
||||
const parsed = parseHookCommand(cmd);
|
||||
assert.equal(parsed.hookPath, '/tmp/.gemini/hooks/gsd-statusline.js');
|
||||
});
|
||||
|
||||
test('portableHooks global install: .js hook still uses absolute node (only the path is $HOME-relative)', () => {
|
||||
const home = require('node:os').homedir().replace(/\\/g, '/');
|
||||
const configDir = home + '/.gemini';
|
||||
const cmd = buildHookCommand(configDir, 'gsd-check-update.js', { portableHooks: true });
|
||||
const parsed = parseHookCommand(cmd);
|
||||
assert.notEqual(parsed.runner, 'node', `portableHooks must also use absolute node (#2979): ${cmd}`);
|
||||
assert.equal(parsed.hookPath, '$HOME/.gemini/hooks/gsd-check-update.js');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bug #3362 / #3413: Windows hook commands are runtime-aware', () => {
|
||||
test('Gemini global install: .js hook command starts with & so quoted runners execute in PowerShell', () => {
|
||||
const cmd = buildHookCommand('C:/Program Files/Gemini/.gemini', 'gsd-check-update.js', {
|
||||
platform: 'win32',
|
||||
runtime: 'gemini',
|
||||
});
|
||||
assert.ok(cmd.startsWith('& '), `Gemini PowerShell commands need call operator, got: ${cmd}`);
|
||||
assert.ok(cmd.includes('"C:/Program Files/Gemini/.gemini/hooks/gsd-check-update.js"'));
|
||||
});
|
||||
|
||||
test('Gemini portable install: .js hook command also uses & on Windows PowerShell', () => {
|
||||
const home = require('node:os').homedir().replace(/\\/g, '/');
|
||||
const cmd = buildHookCommand(`${home}/.gemini`, 'gsd-check-update.js', {
|
||||
portableHooks: true,
|
||||
platform: 'win32',
|
||||
runtime: 'gemini',
|
||||
});
|
||||
assert.ok(cmd.startsWith('& '), `Gemini PowerShell commands need call operator, got: ${cmd}`);
|
||||
assert.equal(parseHookCommand(cmd.slice(2)).hookPath, '$HOME/.gemini/hooks/gsd-check-update.js');
|
||||
});
|
||||
|
||||
test('Claude global install: .js hook command stays shell-neutral on Windows Git Bash', () => {
|
||||
const cmd = buildHookCommand('C:/Users/me/.claude', 'gsd-check-update.js', {
|
||||
platform: 'win32',
|
||||
runtime: 'claude',
|
||||
});
|
||||
assert.ok(!cmd.startsWith('& '), `Claude hook command must not use PowerShell call operator: ${cmd}`);
|
||||
assert.equal(parseHookCommand(cmd).hookPath, 'C:/Users/me/.claude/hooks/gsd-check-update.js');
|
||||
});
|
||||
|
||||
test('Windows .js hook with no runtime stays shell-neutral', () => {
|
||||
const cmd = buildHookCommand('C:/Users/me/.claude', 'gsd-check-update.js', {
|
||||
platform: 'win32',
|
||||
});
|
||||
assert.ok(!cmd.startsWith('& '), `Missing runtime must not imply PowerShell syntax: ${cmd}`);
|
||||
assert.equal(parseHookCommand(cmd).hookPath, 'C:/Users/me/.claude/hooks/gsd-check-update.js');
|
||||
});
|
||||
|
||||
test('Gemini runtime on non-Windows platform does not get PowerShell syntax', () => {
|
||||
const cmd = buildHookCommand('/home/me/.claude', 'gsd-check-update.js', {
|
||||
platform: 'linux',
|
||||
runtime: 'gemini',
|
||||
});
|
||||
assert.ok(!cmd.startsWith('& '), `Non-Windows Gemini hook must stay shell-neutral: ${cmd}`);
|
||||
assert.equal(parseHookCommand(cmd).hookPath, '/home/me/.claude/hooks/gsd-check-update.js');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bug #2979: buildHookCommand for .sh hooks still uses bare "bash" (POSIX std PATH always has /bin)', () => {
|
||||
test('.sh hook runner is exactly "bash" — bash is in /usr/bin:/bin and resolves under minimal PATH', () => {
|
||||
const cmd = buildHookCommand('/tmp/.claude', 'gsd-session-state.sh', { platform: 'linux' });
|
||||
const parsed = parseHookCommand(cmd);
|
||||
assert.equal(parsed.runner, 'bash');
|
||||
});
|
||||
|
||||
test('Windows .sh hook uses resolved Git Bash path instead of bare bash (#3393)', () => {
|
||||
const cmd = buildHookCommand('C:/Users/me/.codex', 'gsd-validate-commit.sh', {
|
||||
platform: 'win32',
|
||||
env: { ProgramFiles: 'C:\\Program Files' },
|
||||
existsSync: (candidate) => candidate === 'C:\\Program Files\\Git\\bin\\bash.exe',
|
||||
});
|
||||
assert.equal(
|
||||
cmd,
|
||||
'"C:/Program Files/Git/bin/bash.exe" "C:/Users/me/.codex/hooks/gsd-validate-commit.sh"',
|
||||
);
|
||||
});
|
||||
|
||||
test('Windows .sh hook returns null when no supported Bash runner is found (#3393)', () => {
|
||||
const cmd = buildHookCommand('C:/Users/me/.codex', 'gsd-phase-boundary.sh', {
|
||||
platform: 'win32',
|
||||
env: {},
|
||||
existsSync: () => false,
|
||||
});
|
||||
assert.equal(cmd, null);
|
||||
});
|
||||
|
||||
test('Windows Claude .sh hook omits explicit bash.exe wrapper (#166)', () => {
|
||||
const cmd = buildHookCommand('C:/Users/me/.claude', 'gsd-session-state.sh', {
|
||||
platform: 'win32',
|
||||
runtime: 'claude',
|
||||
env: { ProgramFiles: 'C:\\Program Files' },
|
||||
existsSync: (candidate) => candidate === 'C:\\Program Files\\Git\\bin\\bash.exe',
|
||||
});
|
||||
assert.equal(
|
||||
cmd,
|
||||
'"C:/Users/me/.claude/hooks/gsd-session-state.sh"',
|
||||
'Claude win32 .sh hooks should serialize as script-only commands'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #3002 CR follow-up: legacy-bare-node migration ─────────────────────────
|
||||
|
||||
const { rewriteLegacyManagedNodeHookCommands } = INSTALL;
|
||||
|
||||
describe('Bug #2979 (#3002 CR): rewriteLegacyManagedNodeHookCommands rewrites bare-node managed hooks on reinstall', () => {
|
||||
test('exported as a function', () => {
|
||||
assert.equal(typeof rewriteLegacyManagedNodeHookCommands, 'function');
|
||||
});
|
||||
|
||||
test('rewrites a managed hook entry that uses bare `node ` to the absolute runner', () => {
|
||||
const settings = {
|
||||
hooks: {
|
||||
SessionStart: [{
|
||||
hooks: [
|
||||
{ type: 'command', command: 'node "/Users/x/.gemini/hooks/gsd-check-update.js"' },
|
||||
],
|
||||
}],
|
||||
},
|
||||
};
|
||||
const runner = '"/usr/local/bin/node"';
|
||||
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner);
|
||||
assert.equal(changed, true);
|
||||
assert.equal(
|
||||
settings.hooks.SessionStart[0].hooks[0].command,
|
||||
'"/usr/local/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"',
|
||||
);
|
||||
});
|
||||
|
||||
test('does NOT touch entries that already use a quoted absolute runner', () => {
|
||||
const settings = {
|
||||
hooks: {
|
||||
SessionStart: [{
|
||||
hooks: [{ type: 'command', command: '"/usr/local/bin/node" "/x/hooks/gsd-statusline.js"' }],
|
||||
}],
|
||||
},
|
||||
};
|
||||
const runner = '"/usr/local/bin/node"';
|
||||
const before = settings.hooks.SessionStart[0].hooks[0].command;
|
||||
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner);
|
||||
assert.equal(changed, false);
|
||||
assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before);
|
||||
});
|
||||
|
||||
test('Gemini on Windows adds PowerShell call operator to existing quoted managed hooks', () => {
|
||||
const settings = {
|
||||
hooks: {
|
||||
SessionStart: [{
|
||||
hooks: [{ type: 'command', command: '"/usr/local/bin/node" "C:/Program Files/Gemini/.gemini/hooks/gsd-check-update.js"' }],
|
||||
}],
|
||||
},
|
||||
};
|
||||
const runner = '"/usr/local/bin/node"';
|
||||
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'win32', runtime: 'gemini' });
|
||||
assert.equal(changed, true);
|
||||
assert.equal(
|
||||
settings.hooks.SessionStart[0].hooks[0].command,
|
||||
'& "/usr/local/bin/node" "C:/Program Files/Gemini/.gemini/hooks/gsd-check-update.js"',
|
||||
);
|
||||
});
|
||||
|
||||
test('Gemini on Windows does NOT double-prefix managed hooks that already use the PowerShell call operator', () => {
|
||||
const settings = {
|
||||
hooks: {
|
||||
SessionStart: [{
|
||||
hooks: [{ type: 'command', command: '& "/usr/local/bin/node" "C:/Program Files/Gemini/.gemini/hooks/gsd-check-update.js"' }],
|
||||
}],
|
||||
},
|
||||
};
|
||||
const runner = '"/usr/local/bin/node"';
|
||||
const before = settings.hooks.SessionStart[0].hooks[0].command;
|
||||
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'win32', runtime: 'gemini' });
|
||||
assert.equal(changed, false);
|
||||
assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before);
|
||||
});
|
||||
|
||||
test('Gemini on Windows rewrites PowerShell bare-node managed hooks to absolute runner without dropping &', () => {
|
||||
const settings = {
|
||||
hooks: {
|
||||
SessionStart: [{
|
||||
hooks: [{ type: 'command', command: '& node "C:/Users/me/.gemini/hooks/gsd-check-update.js"' }],
|
||||
}],
|
||||
},
|
||||
};
|
||||
const runner = '"/usr/local/bin/node"';
|
||||
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'win32', runtime: 'gemini' });
|
||||
assert.equal(changed, true);
|
||||
assert.equal(
|
||||
settings.hooks.SessionStart[0].hooks[0].command,
|
||||
'& "/usr/local/bin/node" "C:/Users/me/.gemini/hooks/gsd-check-update.js"',
|
||||
);
|
||||
});
|
||||
|
||||
test('Claude on Windows strips stale PowerShell prefix from managed hooks on reinstall (#3413)', () => {
|
||||
const settings = {
|
||||
hooks: {
|
||||
SessionStart: [{
|
||||
hooks: [{ type: 'command', command: '& "/usr/local/bin/node" "C:/Users/me/.claude/hooks/gsd-check-update.js"' }],
|
||||
}],
|
||||
},
|
||||
};
|
||||
const runner = '"/usr/local/bin/node"';
|
||||
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'win32', runtime: 'claude' });
|
||||
assert.equal(changed, true);
|
||||
assert.equal(
|
||||
settings.hooks.SessionStart[0].hooks[0].command,
|
||||
'"/usr/local/bin/node" "C:/Users/me/.claude/hooks/gsd-check-update.js"',
|
||||
);
|
||||
});
|
||||
|
||||
test('does NOT touch user-authored bare-node hooks (filename not in managed allowlist)', () => {
|
||||
const settings = {
|
||||
hooks: {
|
||||
SessionStart: [{
|
||||
hooks: [{ type: 'command', command: 'node /home/me/my-custom-hook.js' }],
|
||||
}],
|
||||
},
|
||||
};
|
||||
const runner = '"/usr/local/bin/node"';
|
||||
const before = settings.hooks.SessionStart[0].hooks[0].command;
|
||||
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner);
|
||||
assert.equal(changed, false);
|
||||
assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before);
|
||||
});
|
||||
|
||||
test('does NOT touch .sh hooks (they correctly use bare bash)', () => {
|
||||
const settings = {
|
||||
hooks: {
|
||||
SessionStart: [{
|
||||
hooks: [{ type: 'command', command: 'bash "/x/hooks/gsd-session-state.sh"' }],
|
||||
}],
|
||||
},
|
||||
};
|
||||
const runner = '"/usr/local/bin/node"';
|
||||
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner);
|
||||
assert.equal(changed, false);
|
||||
});
|
||||
|
||||
test('is a no-op when absoluteRunner is null (resolveNodeRunner failed)', () => {
|
||||
const settings = {
|
||||
hooks: {
|
||||
SessionStart: [{
|
||||
hooks: [{ type: 'command', command: 'node "/x/hooks/gsd-check-update.js"' }],
|
||||
}],
|
||||
},
|
||||
};
|
||||
const before = settings.hooks.SessionStart[0].hooks[0].command;
|
||||
const changed = rewriteLegacyManagedNodeHookCommands(settings, null);
|
||||
assert.equal(changed, false);
|
||||
assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before);
|
||||
});
|
||||
|
||||
// #3002 CR: substring containment was a false-positive vector.
|
||||
// User-authored hooks whose path happened to CONTAIN a managed filename
|
||||
// as a substring would get unconditionally rewritten with the GSD runner.
|
||||
// The fix matches by basename equality.
|
||||
test('does NOT rewrite a user hook whose path contains a managed filename as a substring', () => {
|
||||
const settings = {
|
||||
hooks: {
|
||||
SessionStart: [{
|
||||
hooks: [{
|
||||
type: 'command',
|
||||
// Path contains gsd-check-update.js as substring of a longer
|
||||
// filename, but is NOT actually that file.
|
||||
command: 'node /home/me/scripts/wraps-gsd-check-update.js-helper.js',
|
||||
}],
|
||||
}],
|
||||
},
|
||||
};
|
||||
const runner = '"/usr/local/bin/node"';
|
||||
const before = settings.hooks.SessionStart[0].hooks[0].command;
|
||||
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner);
|
||||
assert.equal(changed, false, 'must not rewrite user hooks with managed-filename-as-substring paths');
|
||||
assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before);
|
||||
});
|
||||
|
||||
test('rewrites a managed entry whose path is quoted with single quotes', () => {
|
||||
const settings = {
|
||||
hooks: {
|
||||
SessionStart: [{
|
||||
hooks: [{ type: 'command', command: "node '/x/hooks/gsd-statusline.js'" }],
|
||||
}],
|
||||
},
|
||||
};
|
||||
const runner = '"/usr/local/bin/node"';
|
||||
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'linux' });
|
||||
assert.equal(changed, true);
|
||||
assert.equal(
|
||||
settings.hooks.SessionStart[0].hooks[0].command,
|
||||
`"/usr/local/bin/node" '/x/hooks/gsd-statusline.js'`,
|
||||
);
|
||||
});
|
||||
|
||||
test('rewrites a managed entry with no path quoting (bareword)', () => {
|
||||
const settings = {
|
||||
hooks: {
|
||||
SessionStart: [{
|
||||
hooks: [{ type: 'command', command: 'node /x/hooks/gsd-context-monitor.js' }],
|
||||
}],
|
||||
},
|
||||
};
|
||||
const runner = '"/usr/local/bin/node"';
|
||||
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'linux' });
|
||||
assert.equal(changed, true);
|
||||
assert.equal(
|
||||
settings.hooks.SessionStart[0].hooks[0].command,
|
||||
'"/usr/local/bin/node" /x/hooks/gsd-context-monitor.js',
|
||||
);
|
||||
});
|
||||
|
||||
test('handles Windows-style backslash path separators when extracting basename', () => {
|
||||
const settings = {
|
||||
hooks: {
|
||||
SessionStart: [{
|
||||
hooks: [{ type: 'command', command: 'node "C:\\\\Users\\\\me\\\\.claude\\\\hooks\\\\gsd-prompt-guard.js"' }],
|
||||
}],
|
||||
},
|
||||
};
|
||||
const runner = '"/usr/local/bin/node"';
|
||||
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner);
|
||||
assert.equal(changed, true);
|
||||
});
|
||||
|
||||
test('Gemini on Windows normalizes single-quoted managed hook paths to double-quoted forward-slash paths (#3392)', () => {
|
||||
const settings = {
|
||||
hooks: {
|
||||
PreToolUse: [{
|
||||
hooks: [{
|
||||
type: 'command',
|
||||
command: "node 'C:\\Users\\me\\.gemini\\hooks\\gsd-prompt-guard.js'",
|
||||
}],
|
||||
}],
|
||||
},
|
||||
};
|
||||
const runner = '"C:/nvm4w/nodejs/node.exe"';
|
||||
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner, { platform: 'win32', runtime: 'gemini' });
|
||||
assert.equal(changed, true);
|
||||
assert.equal(
|
||||
settings.hooks.PreToolUse[0].hooks[0].command,
|
||||
'& "C:/nvm4w/nodejs/node.exe" "C:/Users/me/.gemini/hooks/gsd-prompt-guard.js"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bug #2979 (#3002 CR): resolveNodeRunner returns null when execPath unavailable', () => {
|
||||
test('returns null instead of bare "node" when process.execPath is empty', () => {
|
||||
const orig = process.execPath;
|
||||
try {
|
||||
Object.defineProperty(process, 'execPath', { value: '', configurable: true });
|
||||
const r = resolveNodeRunner();
|
||||
assert.equal(r, null, 'expected null, not bare "node"');
|
||||
} finally {
|
||||
Object.defineProperty(process, 'execPath', { value: orig, configurable: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('buildHookCommand returns null when execPath is unavailable (caller skips registration)', () => {
|
||||
const orig = process.execPath;
|
||||
try {
|
||||
Object.defineProperty(process, 'execPath', { value: '', configurable: true });
|
||||
const cmd = buildHookCommand('/tmp/.claude', 'gsd-statusline.js');
|
||||
assert.equal(cmd, null);
|
||||
} finally {
|
||||
Object.defineProperty(process, 'execPath', { value: orig, configurable: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #3002 CR follow-up #2: null-command guards in settings.json ──────────
|
||||
|
||||
const { validateHookFields } = INSTALL;
|
||||
|
||||
describe('Bug #2979 (#3002 CR follow-up): no command:null hook entries survive serialization', () => {
|
||||
// CR feedback: assert structurally on the resulting settings object, not by
|
||||
// grepping bin/install.js source. The push-site guards (each `if` clause's
|
||||
// `&& <command>` token) skip null-command pushes at the source. As a
|
||||
// backstop, install.js now runs validateHookFields(settings) right before
|
||||
// writeSettings; this test exercises that backstop directly.
|
||||
//
|
||||
// Construct a settings object that contains exactly the kind of null-command
|
||||
// entries that the registration code would have written if my push-site
|
||||
// guards regressed. Run validateHookFields on it. Assert the null entries
|
||||
// are gone and the well-formed entries survive.
|
||||
|
||||
function nullCommandEntry(matcher) {
|
||||
const entry = { hooks: [{ type: 'command', command: null }] };
|
||||
if (matcher) entry.matcher = matcher;
|
||||
return entry;
|
||||
}
|
||||
function realCommandEntry(matcher, command) {
|
||||
const entry = { hooks: [{ type: 'command', command }] };
|
||||
if (matcher) entry.matcher = matcher;
|
||||
return entry;
|
||||
}
|
||||
|
||||
const MANAGED_JS_HOOKS = [
|
||||
{ event: 'SessionStart', matcher: undefined, label: 'gsd-check-update.js' },
|
||||
{ event: 'PostToolUse', matcher: 'Bash|Edit|Write|MultiEdit|Agent|Task', label: 'gsd-context-monitor.js' },
|
||||
{ event: 'PreToolUse', matcher: 'Write|Edit', label: 'gsd-prompt-guard.js' },
|
||||
{ event: 'PreToolUse', matcher: 'Write|Edit', label: 'gsd-read-guard.js' },
|
||||
{ event: 'PostToolUse', matcher: 'Read', label: 'gsd-read-injection-scanner.js' },
|
||||
{ event: 'PreToolUse', matcher: 'Bash|Edit|Write|MultiEdit', label: 'gsd-workflow-guard.js' },
|
||||
];
|
||||
|
||||
for (const { event, matcher, label } of MANAGED_JS_HOOKS) {
|
||||
test(`validateHookFields strips a null-command ${label} entry from settings.hooks.${event}`, () => {
|
||||
const settings = {
|
||||
hooks: {
|
||||
[event]: [
|
||||
nullCommandEntry(matcher),
|
||||
realCommandEntry(matcher, '"/usr/local/bin/node" "/x/hooks/other.js"'),
|
||||
],
|
||||
},
|
||||
};
|
||||
const out = validateHookFields(settings);
|
||||
const survivors = out.hooks[event] || [];
|
||||
// The well-formed entry must remain.
|
||||
assert.equal(survivors.length, 1, `expected the real-command entry to survive`);
|
||||
// No survivor entry contains a hook with command === null.
|
||||
for (const e of survivors) {
|
||||
for (const h of e.hooks || []) {
|
||||
assert.notEqual(h.command, null, 'no surviving hook should have command:null');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('validateHookFields drops the entry entirely when all its hooks have null commands', () => {
|
||||
const settings = {
|
||||
hooks: {
|
||||
SessionStart: [nullCommandEntry()],
|
||||
},
|
||||
};
|
||||
const out = validateHookFields(settings);
|
||||
// Empty event arrays should be cleaned up (the entire SessionStart key
|
||||
// gets removed when nothing valid remains).
|
||||
assert.ok(
|
||||
!out.hooks.SessionStart || out.hooks.SessionStart.length === 0,
|
||||
'expected SessionStart to be empty/removed after the only entry was dropped',
|
||||
);
|
||||
});
|
||||
|
||||
test('validateHookFields preserves agent-type hooks while stripping command:null sibling hooks', () => {
|
||||
const settings = {
|
||||
hooks: {
|
||||
SessionStart: [{
|
||||
hooks: [
|
||||
{ type: 'command', command: null },
|
||||
{ type: 'agent', prompt: 'analyze the session' },
|
||||
{ type: 'command', command: '"/usr/local/bin/node" "/x/hooks/y.js"' },
|
||||
],
|
||||
}],
|
||||
},
|
||||
};
|
||||
const out = validateHookFields(settings);
|
||||
const survivors = out.hooks.SessionStart[0].hooks;
|
||||
assert.equal(survivors.length, 2, 'expected 2 of 3 hooks to survive (the null-command one is stripped)');
|
||||
assert.equal(survivors.find(h => h.command === null), undefined, 'no surviving hook should have command:null');
|
||||
});
|
||||
});
|
||||
@@ -1,203 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Bug #2998: gsd-pristine/ snapshot is documented but never populated by
|
||||
* the installer. saveLocalPatches declared a pristineDir variable and
|
||||
* promised "saves pristine copies (from manifest) to gsd-pristine/ to
|
||||
* enable three-way merge during reapply-patches" -- but no code ever
|
||||
* wrote to that directory. Effect: the /gsd-reapply-patches Step 5
|
||||
* verifier (#2972) silently degrades to its over-broad fallback heuristic
|
||||
* ("every significant backup line"), exactly the silent-success-on-lost-
|
||||
* content failure mode #2969 was designed to prevent.
|
||||
*
|
||||
* Fix: new populatePristineDir({...}) helper runs the install transform
|
||||
* pipeline (copyWithPathReplacement) into a tmp staging dir, then copies
|
||||
* out the modified-file paths into gsd-pristine/. saveLocalPatches now
|
||||
* accepts a pristineCtx and calls the helper when local patches are
|
||||
* detected.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const INSTALL = require(path.join(ROOT, 'bin', 'install.js'));
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
function sha256(content) {
|
||||
return crypto.createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
describe('Bug #2998: populatePristineDir is exported and writes pristine for modified files', () => {
|
||||
test('exported as a function', () => {
|
||||
assert.equal(typeof INSTALL.populatePristineDir, 'function',
|
||||
'expected populatePristineDir in install.js exports (#2998)');
|
||||
});
|
||||
|
||||
test('returns 0 when no files are modified (no-op)', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-'));
|
||||
try {
|
||||
const written = INSTALL.populatePristineDir({
|
||||
packageSrc: ROOT,
|
||||
pristineDir: path.join(tmp, 'gsd-pristine'),
|
||||
modified: [],
|
||||
runtime: 'claude',
|
||||
pathPrefix: '$HOME/.claude/',
|
||||
isGlobal: true,
|
||||
});
|
||||
assert.equal(written, 0);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('writes one pristine file per modified path that exists in source', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-'));
|
||||
const pristineDir = path.join(tmp, 'gsd-pristine');
|
||||
try {
|
||||
// Pick a real installed-side relPath from the package source. The
|
||||
// install transforms map source `gsd-core/<rel>` to installed
|
||||
// `gsd-core/<rel>` for skills-aware runtimes (like claude),
|
||||
// so the relPath is the same on both sides.
|
||||
const candidate = path.join('gsd-core', 'workflows', 'reapply-patches.md');
|
||||
const sourcePath = path.join(ROOT, candidate);
|
||||
assert.equal(fs.existsSync(sourcePath), true,
|
||||
`precondition: source file exists at ${candidate}`);
|
||||
const written = INSTALL.populatePristineDir({
|
||||
packageSrc: ROOT,
|
||||
pristineDir,
|
||||
modified: [candidate],
|
||||
runtime: 'claude',
|
||||
pathPrefix: '$HOME/.claude/',
|
||||
isGlobal: true,
|
||||
});
|
||||
assert.equal(written, 1, 'expected exactly one pristine file written');
|
||||
const out = path.join(pristineDir, candidate);
|
||||
assert.equal(fs.existsSync(out), true, `expected pristine file at ${out}`);
|
||||
// The pristine content should be the transformed version (not raw source):
|
||||
// copyWithPathReplacement substitutes ~/.claude/ for the runtime path prefix.
|
||||
// For claude+global, the prefix is $HOME/.claude/ which equals the original,
|
||||
// so the transform is effectively identity here. We assert the content is a
|
||||
// non-empty markdown file rather than asserting on transform specifics.
|
||||
const content = fs.readFileSync(out, 'utf-8');
|
||||
assert.ok(content.length > 0, 'pristine file should be non-empty');
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('skips paths not present in source (does not corrupt pristine with stale data)', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-'));
|
||||
const pristineDir = path.join(tmp, 'gsd-pristine');
|
||||
try {
|
||||
const written = INSTALL.populatePristineDir({
|
||||
packageSrc: ROOT,
|
||||
pristineDir,
|
||||
modified: ['gsd-core/this-path-does-not-exist.md'],
|
||||
runtime: 'claude',
|
||||
pathPrefix: '$HOME/.claude/',
|
||||
isGlobal: true,
|
||||
});
|
||||
assert.equal(written, 0, 'expected zero pristine files for non-existent source paths');
|
||||
const out = path.join(pristineDir, 'gsd-core/this-path-does-not-exist.md');
|
||||
assert.equal(fs.existsSync(out), false, 'pristine should not contain ghost paths');
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('pristine files have stable content (transformations are deterministic)', () => {
|
||||
// Determinism is what makes the verifier's hash check meaningful:
|
||||
// backup-meta.json records pristine_hashes computed at this same step,
|
||||
// so re-running with the same inputs must yield byte-identical files.
|
||||
const tmp1 = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-d1-'));
|
||||
const tmp2 = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-d2-'));
|
||||
try {
|
||||
const candidate = path.join('gsd-core', 'workflows', 'reapply-patches.md');
|
||||
const ctx = {
|
||||
packageSrc: ROOT,
|
||||
modified: [candidate],
|
||||
runtime: 'claude',
|
||||
pathPrefix: '$HOME/.claude/',
|
||||
isGlobal: true,
|
||||
};
|
||||
INSTALL.populatePristineDir(Object.assign({ pristineDir: path.join(tmp1, 'gsd-pristine') }, ctx));
|
||||
INSTALL.populatePristineDir(Object.assign({ pristineDir: path.join(tmp2, 'gsd-pristine') }, ctx));
|
||||
const a = fs.readFileSync(path.join(tmp1, 'gsd-pristine', candidate));
|
||||
const b = fs.readFileSync(path.join(tmp2, 'gsd-pristine', candidate));
|
||||
assert.equal(sha256(a), sha256(b), 'two runs of the same inputs must yield identical pristine content');
|
||||
} finally {
|
||||
cleanup(tmp1);
|
||||
cleanup(tmp2);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #3004 CR follow-up: multi-root pristine expansion ─────────────────────
|
||||
|
||||
describe('Bug #2998 (#3004 CR): pristine expansion covers every manifest install root', () => {
|
||||
test('paths under agents/ are staged via copyWithPathReplacement, not silently skipped', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-multi-'));
|
||||
const pristineDir = path.join(tmp, 'gsd-pristine');
|
||||
try {
|
||||
const candidate = path.join('agents', 'gsd-planner.md');
|
||||
const sourcePath = path.join(ROOT, candidate);
|
||||
assert.equal(fs.existsSync(sourcePath), true,
|
||||
`precondition: source file exists at ${candidate}`);
|
||||
const written = INSTALL.populatePristineDir({
|
||||
packageSrc: ROOT,
|
||||
pristineDir,
|
||||
modified: [candidate],
|
||||
runtime: 'claude',
|
||||
pathPrefix: '$HOME/.claude/',
|
||||
isGlobal: true,
|
||||
});
|
||||
assert.equal(written, 1, 'expected agents/ path to be staged and copied to pristine');
|
||||
assert.equal(fs.existsSync(path.join(pristineDir, candidate)), true);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('a mix of gsd-core/ and agents/ paths in modified list are all staged', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-mix-'));
|
||||
const pristineDir = path.join(tmp, 'gsd-pristine');
|
||||
try {
|
||||
const a = path.join('gsd-core', 'workflows', 'reapply-patches.md');
|
||||
const b = path.join('agents', 'gsd-planner.md');
|
||||
assert.equal(fs.existsSync(path.join(ROOT, a)), true);
|
||||
assert.equal(fs.existsSync(path.join(ROOT, b)), true);
|
||||
const written = INSTALL.populatePristineDir({
|
||||
packageSrc: ROOT,
|
||||
pristineDir,
|
||||
modified: [a, b],
|
||||
runtime: 'claude',
|
||||
pathPrefix: '$HOME/.claude/',
|
||||
isGlobal: true,
|
||||
});
|
||||
assert.equal(written, 2, 'expected both top-level dirs to be staged');
|
||||
assert.equal(fs.existsSync(path.join(pristineDir, a)), true);
|
||||
assert.equal(fs.existsSync(path.join(pristineDir, b)), true);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bug #2998: saveLocalPatches no longer leaves the pristineDir variable unused', () => {
|
||||
test('saveLocalPatches accepts a pristineCtx and exposes the helper for direct testing', () => {
|
||||
// Structural assertion: the function exists with the new signature shape.
|
||||
// Behavioral end-to-end is covered by the populatePristineDir tests above
|
||||
// (that helper is what saveLocalPatches calls internally).
|
||||
assert.equal(typeof INSTALL.populatePristineDir, 'function');
|
||||
// The signature for saveLocalPatches isn't exported, but the helper IS,
|
||||
// and it's the unit of behavior the bug is about. Asserting on the helper
|
||||
// is the structural-IR equivalent of the no-source-grep convention.
|
||||
});
|
||||
});
|
||||
@@ -1,258 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Bug #3017: Codex SessionStart hook still emits bare `node` after #3002.
|
||||
*
|
||||
* PR #3002 fixed #2979 for settings.json-based managed JS hooks (Claude
|
||||
* Code, Gemini, Antigravity) by routing through buildHookCommand() →
|
||||
* resolveNodeRunner(), which emits the absolute Node binary path. But the
|
||||
* Codex install path writes its SessionStart hook directly into a
|
||||
* config.toml string, bypassing both helpers:
|
||||
*
|
||||
* command = "node ${updateCheckScript}"
|
||||
*
|
||||
* Under a GUI/minimal PATH (`/usr/bin:/bin:/usr/sbin:/sbin`) where node
|
||||
* is not resolvable, the hook fails with `/bin/sh: node: command not
|
||||
* found` (exit 127). The same failure mode #2979 was meant to fix —
|
||||
* just on the codex toml branch instead of the settings.json branch.
|
||||
*
|
||||
* The fix exposes two pure helpers and tests them as typed records,
|
||||
* not by grepping install.js content:
|
||||
*
|
||||
* buildCodexHookBlock(targetDir, { absoluteRunner }) → toml string
|
||||
* - emits `command = "<absoluteRunner> <quoted hook path>"` so the
|
||||
* hook resolves under minimal PATH.
|
||||
* - returns null when absoluteRunner is null (caller skips with warn,
|
||||
* matching settings.json branch behavior).
|
||||
*
|
||||
* rewriteLegacyCodexHookBlock(tomlContent, absoluteRunner) → { content, changed }
|
||||
* - rewrites an existing bare-node managed-hook command on reinstall
|
||||
* (matches the rewriteLegacyManagedNodeHookCommands shape from #3002).
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
|
||||
const INSTALL = require(path.join(__dirname, '..', 'bin', 'install.js'));
|
||||
const projection = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'shell-command-projection.cjs'));
|
||||
const { buildCodexHookBlock, rewriteLegacyCodexHookBlock, resolveNodeRunner } = INSTALL;
|
||||
const { projectCodexHookTomlCommand } = projection;
|
||||
|
||||
/**
|
||||
* Parse the toml hook block into a typed record so tests can assert on
|
||||
* the structured shape (what's the runner, what's the hook path, what's
|
||||
* the type) rather than substring-matching the toml text.
|
||||
*/
|
||||
function parseCodexHookBlock(block) {
|
||||
if (!block) return { ok: false, reason: 'empty' };
|
||||
// The block always carries the "# GSD Hooks" marker, the AoT tables,
|
||||
// a type=command, and a command="<runner> <quoted-hook-path>" line.
|
||||
const hasMarker = /^# GSD Hooks$/m.test(block);
|
||||
const hasEvent = /^\[\[hooks\.SessionStart\]\]$/m.test(block);
|
||||
const hasHandler = /^\[\[hooks\.SessionStart\.hooks\]\]$/m.test(block);
|
||||
const typeMatch = block.match(/^type\s*=\s*"([^"]+)"$/m);
|
||||
// command = "<runner> <hookpath>" — runner may itself be a quoted absolute path.
|
||||
// Match the whole RHS as one toml double-quoted string, then split into runner + hookpath.
|
||||
const cmdLine = block.match(/^command\s*=\s*"((?:[^"\\]|\\.)*)"$/m);
|
||||
if (!cmdLine) return { ok: false, reason: 'no command line' };
|
||||
const cmdValue = cmdLine[1];
|
||||
// Inside the command value, the runner is either a quoted string (escaped \" in toml)
|
||||
// or a bare token, followed by a space and the hook path (quoted).
|
||||
// toml escapes interior " as \", so the cmdValue contains literal \" sequences.
|
||||
const cmdParsed = cmdValue.match(/^(\\".+?\\"|node|bash|\S+)\s+\\"([^\\]+)\\"\s*$/);
|
||||
return {
|
||||
ok: true,
|
||||
hasMarker,
|
||||
hasEvent,
|
||||
hasHandler,
|
||||
type: typeMatch ? typeMatch[1] : null,
|
||||
command: cmdValue,
|
||||
runner: cmdParsed ? cmdParsed[1] : null,
|
||||
hookPath: cmdParsed ? cmdParsed[2] : null,
|
||||
};
|
||||
}
|
||||
|
||||
// Strip the toml-escape (\") and JSON-quote (") layers from the parsed
|
||||
// runner token to compare against the raw absolute path the caller
|
||||
// supplied. parsed.runner round-trips through TWO escape layers:
|
||||
// 1. JSON.stringify in resolveNodeRunner adds outer "..." quotes
|
||||
// 2. toml escapes the interior " to \" inside the command field
|
||||
// After both, parsed.runner ends in `\"` and starts with `\"`.
|
||||
function unescapeRunner(token) {
|
||||
if (!token) return token;
|
||||
let t = token.replace(/^\\"/, '').replace(/\\"$/, '');
|
||||
if (t.startsWith('"') && t.endsWith('"')) t = t.slice(1, -1);
|
||||
return t;
|
||||
}
|
||||
|
||||
describe('Bug #3017 / #3440: Codex hook projection seam', () => {
|
||||
test('projectCodexHookTomlCommand renders escaped command value from shared projection module', () => {
|
||||
const commandValue = projectCodexHookTomlCommand({
|
||||
absoluteRunner: '"/usr/local/bin/node"',
|
||||
scriptPath: '/tmp/codex-test/.codex/hooks/gsd-check-update.js',
|
||||
platform: 'linux',
|
||||
});
|
||||
assert.equal(
|
||||
commandValue,
|
||||
'\\"/usr/local/bin/node\\" \\"/tmp/codex-test/.codex/hooks/gsd-check-update.js\\"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bug #3017: buildCodexHookBlock emits absolute node runner', () => {
|
||||
test('exported as a function', () => {
|
||||
assert.equal(typeof buildCodexHookBlock, 'function');
|
||||
});
|
||||
|
||||
test('emits the EXACT absolute node runner the caller supplied (#3022 CR)', () => {
|
||||
const targetDir = '/tmp/codex-test/.codex';
|
||||
const expectedRunnerPath = '/usr/local/bin/node';
|
||||
const absoluteRunner = `"${expectedRunnerPath}"`;
|
||||
const block = buildCodexHookBlock(targetDir, { absoluteRunner });
|
||||
const parsed = parseCodexHookBlock(block);
|
||||
assert.equal(parsed.ok, true, `parse failed: ${block}`);
|
||||
assert.equal(parsed.hasMarker, true, '# GSD Hooks marker present');
|
||||
assert.equal(parsed.hasEvent, true, '[[hooks.SessionStart]] AoT entry present');
|
||||
assert.equal(parsed.hasHandler, true, '[[hooks.SessionStart.hooks]] handler entry present');
|
||||
assert.equal(parsed.type, 'command', 'handler is type=command');
|
||||
// Strict: parsed runner must match the supplied absolute path EXACTLY
|
||||
// (after stripping toml/JSON escape layers). A loose substring like
|
||||
// '/node' would let an unrelated absolute token containing '/node'
|
||||
// pass — e.g. '/Users/x/notnode/foo'.
|
||||
assert.equal(unescapeRunner(parsed.runner), expectedRunnerPath,
|
||||
`parsed runner must equal supplied absolute path: got ${parsed.runner}, want ${expectedRunnerPath}`);
|
||||
// On Windows, path.resolve prepends the current drive letter ("D:") to
|
||||
// the POSIX-shaped fixture path. Accept either form.
|
||||
const expectedHookSuffix = '/tmp/codex-test/.codex/hooks/gsd-check-update.js';
|
||||
assert.ok(
|
||||
parsed.hookPath === expectedHookSuffix ||
|
||||
parsed.hookPath.replace(/^[A-Za-z]:/, '') === expectedHookSuffix,
|
||||
`hook path equality, got: ${parsed.hookPath}, want suffix: ${expectedHookSuffix}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('returns null when absoluteRunner is null (caller skips registration)', () => {
|
||||
const block = buildCodexHookBlock('/tmp/x/.codex', { absoluteRunner: null });
|
||||
assert.equal(block, null,
|
||||
'must return null on missing runner so caller can warn-and-skip instead of writing a broken hook');
|
||||
});
|
||||
|
||||
test('integrates with resolveNodeRunner() in the live process — runner equals resolved node runner (#3022 CR)', () => {
|
||||
const runner = resolveNodeRunner();
|
||||
assert.ok(runner, 'resolveNodeRunner returns a usable value in this test env');
|
||||
const block = buildCodexHookBlock('/tmp/x/.codex', { absoluteRunner: runner });
|
||||
const parsed = parseCodexHookBlock(block);
|
||||
assert.equal(parsed.ok, true);
|
||||
// Strict canonical-runner equality: the parsed runner (after stripping
|
||||
// toml + JSON escape layers) must be exactly the normalized runner that
|
||||
// resolveNodeRunner selected. Homebrew Cellar execPath values intentionally
|
||||
// normalize to the stable Homebrew symlink (#3181).
|
||||
const expected = JSON.parse(runner);
|
||||
assert.equal(unescapeRunner(parsed.runner), expected,
|
||||
`parsed runner must equal resolveNodeRunner(), got: ${parsed.runner}, want: ${expected}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bug #3017: rewriteLegacyCodexHookBlock migrates bare-node on reinstall', () => {
|
||||
test('exported as a function', () => {
|
||||
assert.equal(typeof rewriteLegacyCodexHookBlock, 'function');
|
||||
});
|
||||
|
||||
test('rewrites a bare-node managed-hook command to the absolute runner', () => {
|
||||
const before = [
|
||||
'[model]',
|
||||
'name = "o3"',
|
||||
'',
|
||||
'# GSD Hooks',
|
||||
'[[hooks.SessionStart]]',
|
||||
'',
|
||||
'[[hooks.SessionStart.hooks]]',
|
||||
'type = "command"',
|
||||
'command = "node /Users/x/.codex/hooks/gsd-check-update.js"',
|
||||
'',
|
||||
].join('\n');
|
||||
const expectedRunnerPath = '/usr/local/bin/node';
|
||||
const runner = `"${expectedRunnerPath}"`;
|
||||
const result = rewriteLegacyCodexHookBlock(before, runner);
|
||||
assert.equal(result.changed, true, 'must report change=true');
|
||||
// The migrated command must use the EXACT absolute runner the caller
|
||||
// supplied (#3022 CR — was previously asserting a loose '/node'
|
||||
// substring which let unrelated absolute paths pass).
|
||||
const parsed = parseCodexHookBlock(result.content);
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal(unescapeRunner(parsed.runner), expectedRunnerPath,
|
||||
`runner must equal supplied absolute path: ${parsed.runner}`);
|
||||
assert.equal(parsed.hookPath, '/Users/x/.codex/hooks/gsd-check-update.js');
|
||||
// Non-GSD content (the [model] block) must be preserved verbatim.
|
||||
assert.ok(result.content.includes('[model]'));
|
||||
assert.ok(result.content.includes('name = "o3"'));
|
||||
});
|
||||
|
||||
test('decodes TOML-escaped quoted script paths before projection', () => {
|
||||
const before = [
|
||||
'# GSD Hooks',
|
||||
'[[hooks.SessionStart]]',
|
||||
'',
|
||||
'[[hooks.SessionStart.hooks]]',
|
||||
'type = "command"',
|
||||
'command = "node \\"C:\\\\Users\\\\x\\\\.codex\\\\hooks\\\\gsd-check-update.js\\""',
|
||||
'',
|
||||
].join('\n');
|
||||
const runner = '"/usr/local/bin/node"';
|
||||
const result = rewriteLegacyCodexHookBlock(before, runner, { platform: 'win32' });
|
||||
assert.equal(result.changed, true);
|
||||
const parsed = parseCodexHookBlock(result.content);
|
||||
assert.equal(parsed.ok, true, 'hook block must parse correctly');
|
||||
const expected = projectCodexHookTomlCommand({
|
||||
absoluteRunner: runner,
|
||||
scriptPath: 'C:\\Users\\x\\.codex\\hooks\\gsd-check-update.js',
|
||||
platform: 'win32',
|
||||
});
|
||||
assert.equal(parsed.command, expected,
|
||||
'rewritten command must project from decoded Windows path (not TOML-escaped token text)');
|
||||
assert.equal(unescapeRunner(parsed.runner), '/usr/local/bin/node',
|
||||
'runner must equal supplied absolute path');
|
||||
assert.equal(parsed.hookPath, 'C:/Users/x/.codex/hooks/gsd-check-update.js',
|
||||
'hook path must equal decoded Windows path after projection normalization');
|
||||
});
|
||||
|
||||
test('does NOT touch a managed-hook entry that already uses an absolute runner', () => {
|
||||
const already = [
|
||||
'# GSD Hooks',
|
||||
'[[hooks.SessionStart]]',
|
||||
'',
|
||||
'[[hooks.SessionStart.hooks]]',
|
||||
'type = "command"',
|
||||
'command = "\\"/usr/local/bin/node\\" /Users/x/.codex/hooks/gsd-check-update.js"',
|
||||
'',
|
||||
].join('\n');
|
||||
const result = rewriteLegacyCodexHookBlock(already, '"/usr/local/bin/node"');
|
||||
assert.equal(result.changed, false);
|
||||
assert.equal(result.content, already);
|
||||
});
|
||||
|
||||
test('does NOT touch user-authored bare-node hooks (filename not in managed allowlist)', () => {
|
||||
const userOwned = [
|
||||
'[[hooks.SessionStart]]',
|
||||
'',
|
||||
'[[hooks.SessionStart.hooks]]',
|
||||
'type = "command"',
|
||||
'command = "node /home/me/my-custom-codex-hook.js"',
|
||||
'',
|
||||
].join('\n');
|
||||
const result = rewriteLegacyCodexHookBlock(userOwned, '"/usr/local/bin/node"');
|
||||
assert.equal(result.changed, false,
|
||||
'user-authored hooks must be left alone; only managed gsd-* hooks are migrated');
|
||||
assert.equal(result.content, userOwned);
|
||||
});
|
||||
|
||||
test('returns content unchanged when absoluteRunner is null', () => {
|
||||
const before = 'command = "node /path/to/gsd-check-update.js"';
|
||||
const result = rewriteLegacyCodexHookBlock(before, null);
|
||||
assert.equal(result.changed, false);
|
||||
assert.equal(result.content, before);
|
||||
});
|
||||
});
|
||||
@@ -1,178 +0,0 @@
|
||||
/**
|
||||
* Regression test for bug #3018.
|
||||
*
|
||||
* @jon-hendry: running `$gsd-discuss-phase 81` in Codex Default mode (where
|
||||
* `request_user_input` is rejected) caused the agent to pick "reasonable
|
||||
* defaults" and proceed straight into writing CONTEXT.md / DISCUSSION-LOG.md
|
||||
* checkpoints — without ever surfacing the questions to the user. The
|
||||
* generated Codex skill adapter explicitly told it to do that:
|
||||
*
|
||||
* "When `request_user_input` is rejected (Execute mode), present a
|
||||
* plain-text numbered list and pick a reasonable default."
|
||||
*
|
||||
* Discuss-mode is the wrong place for that fallback. The contract should be:
|
||||
* stop, render the questions as plain text, wait for the user's answer.
|
||||
* Defaults may only be picked when the user has authorized non-interactive
|
||||
* mode (--auto / --all) or has explicitly approved them.
|
||||
*
|
||||
* Test design (#3027 CR follow-up): instead of grepping the prose with
|
||||
* regex, parse the fallback section into a typed semantic-flag record and
|
||||
* assert on those booleans. This adheres to CONTRIBUTING.md "no-source-grep"
|
||||
* — the test names a behavioral invariant, the parser walks the prose
|
||||
* once and exposes the invariants as named flags, and the prose can be
|
||||
* reworded freely as long as the flags stay true.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
|
||||
const INSTALL = require(path.join(__dirname, '..', 'bin', 'install.js'));
|
||||
const { getCodexSkillAdapterHeader } = INSTALL;
|
||||
|
||||
/**
|
||||
* Extract the "Execute mode fallback" section text from the adapter header.
|
||||
* Returns null if the section is missing. Section runs from the
|
||||
* "Execute mode fallback:" label up to the next heading or </codex_skill_adapter> tag.
|
||||
*/
|
||||
function extractExecuteModeFallback(header) {
|
||||
const m = header.match(/Execute mode fallback:\s*\n([\s\S]*?)(?=\n##\s|\n<\/codex_skill_adapter>)/);
|
||||
return m ? m[1].trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the Execute-mode-fallback section into a typed semantic-flag
|
||||
* record. Each flag answers a single behavioral question that the #3018
|
||||
* fix is contractually required to encode in the prose. Tests assert on
|
||||
* the booleans, not the wording — so the prose can evolve without test
|
||||
* churn as long as the semantics stay correct.
|
||||
*
|
||||
* The flags are derived from a single pass over the section text: each
|
||||
* one looks for any of a small set of synonym phrases that a correct
|
||||
* implementation would use. The negative anti-pattern flag
|
||||
* (`silentlyPicksDefaults`) is the regression guard — the prose under
|
||||
* #3018 told the agent to "pick a reasonable default" autonomously,
|
||||
* which is exactly what this fix removes.
|
||||
*/
|
||||
function parseExecuteModeFallback(section) {
|
||||
if (!section || typeof section !== 'string') {
|
||||
return {
|
||||
ok: false,
|
||||
sectionLength: 0,
|
||||
instructsStop: false,
|
||||
presentsPlainTextQuestions: false,
|
||||
namesPermissionPath: false,
|
||||
forbidsWritingArtifactsBeforeAnswer: false,
|
||||
silentlyPicksDefaults: false,
|
||||
};
|
||||
}
|
||||
const lower = section.toLowerCase();
|
||||
// (a) STOP/WAIT directive — the agent must halt instead of proceeding.
|
||||
const instructsStop = /\b(stop|halt|wait)\b/.test(lower);
|
||||
// (b) Plain-text fallback presentation — the agent must surface the
|
||||
// questions in some inspectable form (numbered list / plain text).
|
||||
const presentsPlainTextQuestions = /plain.?text|numbered list/.test(lower);
|
||||
// (c) Permission path that DOES allow defaults — must name at least
|
||||
// one (--auto / --all / explicit user approval / autonomous workflow).
|
||||
const namesPermissionPath =
|
||||
/--auto|--all/.test(section) ||
|
||||
/explicit(ly)? (approv|authoriz|consent)/i.test(section) ||
|
||||
/user (has )?approv|user (has )?authoriz|user (has )?consent/i.test(section) ||
|
||||
/autonomous (lifecycle|workflow|paths?)/i.test(section);
|
||||
// (d) Artifact-write ban — the agent must not produce workflow files
|
||||
// (CONTEXT.md, DISCUSSION-LOG.md, PLAN.md, checkpoints) before the
|
||||
// user answers or one of the permission-path conditions applies.
|
||||
// Require BOTH a "do not write" intent AND a named artifact class so
|
||||
// generic "do not write" prose elsewhere can't satisfy the flag.
|
||||
const forbidsWriteIntent = /do not write|don'?t write|must not write|shall not write/i.test(section);
|
||||
const namesArtifactClass = /artifact|checkpoint|context\.md|discussion.?log|plan\.md/i.test(section);
|
||||
const forbidsWritingArtifactsBeforeAnswer = forbidsWriteIntent && namesArtifactClass;
|
||||
// Anti-pattern guard — the prose that caused #3018. This MUST be false.
|
||||
const silentlyPicksDefaults = /pick (a |the )?(reasonable|sensible|sane) default/i.test(section);
|
||||
return {
|
||||
ok: true,
|
||||
sectionLength: section.length,
|
||||
instructsStop,
|
||||
presentsPlainTextQuestions,
|
||||
namesPermissionPath,
|
||||
forbidsWritingArtifactsBeforeAnswer,
|
||||
silentlyPicksDefaults,
|
||||
};
|
||||
}
|
||||
|
||||
describe('bug #3018: codex skill adapter encodes the discuss-mode fallback contract', () => {
|
||||
test('exports the adapter generator', () => {
|
||||
assert.equal(typeof getCodexSkillAdapterHeader, 'function');
|
||||
});
|
||||
|
||||
test('Execute mode fallback section exists and has content', () => {
|
||||
const header = getCodexSkillAdapterHeader('gsd-discuss-phase');
|
||||
const section = extractExecuteModeFallback(header);
|
||||
const parsed = parseExecuteModeFallback(section);
|
||||
assert.equal(parsed.ok, true, `section must parse, got header:\n${header}`);
|
||||
assert.ok(parsed.sectionLength > 0, 'section must be non-empty');
|
||||
});
|
||||
|
||||
test('fallback instructs STOP/WAIT (not silent continuation)', () => {
|
||||
const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase'));
|
||||
const parsed = parseExecuteModeFallback(section);
|
||||
assert.equal(parsed.instructsStop, true,
|
||||
`must instruct stop/halt/wait — section was:\n${section}`);
|
||||
});
|
||||
|
||||
test('fallback prescribes plain-text question presentation', () => {
|
||||
const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase'));
|
||||
const parsed = parseExecuteModeFallback(section);
|
||||
assert.equal(parsed.presentsPlainTextQuestions, true,
|
||||
`must mention plain-text / numbered-list presentation — section was:\n${section}`);
|
||||
});
|
||||
|
||||
test('fallback names a permission path under which defaults ARE allowed (--auto / --all / explicit approval / autonomous)', () => {
|
||||
const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase'));
|
||||
const parsed = parseExecuteModeFallback(section);
|
||||
assert.equal(parsed.namesPermissionPath, true,
|
||||
`must name at least one permission path — section was:\n${section}`);
|
||||
});
|
||||
|
||||
test('fallback forbids writing workflow artifacts before user answers', () => {
|
||||
const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase'));
|
||||
const parsed = parseExecuteModeFallback(section);
|
||||
assert.equal(parsed.forbidsWritingArtifactsBeforeAnswer, true,
|
||||
`must encode write-ban + named artifact class — section was:\n${section}`);
|
||||
});
|
||||
|
||||
test('fallback does NOT contain the #3018 anti-pattern ("pick a reasonable default")', () => {
|
||||
const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase'));
|
||||
const parsed = parseExecuteModeFallback(section);
|
||||
assert.equal(parsed.silentlyPicksDefaults, false,
|
||||
`regression — fallback must NOT instruct the agent to pick defaults autonomously, section was:\n${section}`);
|
||||
});
|
||||
|
||||
test('all four positive flags + the negative anti-pattern flag — typed-record snapshot', () => {
|
||||
// Single assertion that the whole semantic record matches the contract.
|
||||
// If any flag flips, the test fails with a structured diff naming the
|
||||
// exact invariant that broke.
|
||||
const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-discuss-phase'));
|
||||
const parsed = parseExecuteModeFallback(section);
|
||||
const semanticContract = {
|
||||
ok: parsed.ok,
|
||||
instructsStop: parsed.instructsStop,
|
||||
presentsPlainTextQuestions: parsed.presentsPlainTextQuestions,
|
||||
namesPermissionPath: parsed.namesPermissionPath,
|
||||
forbidsWritingArtifactsBeforeAnswer: parsed.forbidsWritingArtifactsBeforeAnswer,
|
||||
silentlyPicksDefaults: parsed.silentlyPicksDefaults,
|
||||
};
|
||||
assert.deepStrictEqual(semanticContract, {
|
||||
ok: true,
|
||||
instructsStop: true,
|
||||
presentsPlainTextQuestions: true,
|
||||
namesPermissionPath: true,
|
||||
forbidsWritingArtifactsBeforeAnswer: true,
|
||||
silentlyPicksDefaults: false,
|
||||
}, `discuss-mode fallback contract violated — section was:\n${section}`);
|
||||
});
|
||||
});
|
||||
@@ -1,192 +0,0 @@
|
||||
/**
|
||||
* Bug #3037: Gemini global+local install creates duplicate /gsd:* commands
|
||||
* across user (HOME/.gemini/) and workspace (PROJECT/.gemini/) scopes.
|
||||
*
|
||||
* Reproduction (from issue body):
|
||||
* 1. install --gemini --global with HOME=tmpHome
|
||||
* 2. cd tmpProject; install --gemini --local
|
||||
* → both ~/.gemini/commands/gsd/ and PROJECT/.gemini/commands/gsd/ contain
|
||||
* 65 overlapping command filenames.
|
||||
* → Gemini conflict detection renames every overlapping command to
|
||||
* /workspace.gsd:* and /user.gsd:*, breaking the documented /gsd:*
|
||||
* namespace.
|
||||
*
|
||||
* Fix: when the local Gemini install detects the user-scope GSD command
|
||||
* directory already exists with managed-shape content, skip the local copy
|
||||
* and emit a clear warning explaining the conflict avoidance.
|
||||
*
|
||||
* Tests assert on the post-install filesystem shape and capture the skip
|
||||
* warning so the full test log remains warning-clean.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createTempDir, cleanup, captureConsole } = require('./helpers.cjs');
|
||||
|
||||
const { install } = require('../bin/install.js');
|
||||
|
||||
describe('bug #3037: Gemini global+local install must not create duplicate command scopes', () => {
|
||||
let tmpHome;
|
||||
let tmpProject;
|
||||
let originalHome;
|
||||
let originalUserprofile;
|
||||
let originalCwd;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = createTempDir('gsd-3037-home-');
|
||||
tmpProject = createTempDir('gsd-3037-work-');
|
||||
originalHome = process.env.HOME;
|
||||
originalUserprofile = process.env.USERPROFILE;
|
||||
originalCwd = process.cwd();
|
||||
// Point HOME at the temp dir so install(true, 'gemini') writes to
|
||||
// tmpHome/.gemini, not the developer's real home.
|
||||
process.env.HOME = tmpHome;
|
||||
process.env.USERPROFILE = tmpHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = originalHome;
|
||||
// CR #3041: also restore USERPROFILE so the temp HOME doesn't leak
|
||||
// into later tests and create order-dependent failures on Windows
|
||||
// or any code path that reads USERPROFILE.
|
||||
if (originalUserprofile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = originalUserprofile;
|
||||
process.chdir(originalCwd);
|
||||
cleanup(tmpHome);
|
||||
cleanup(tmpProject);
|
||||
});
|
||||
|
||||
function listCommandFiles(geminiCommandsRoot) {
|
||||
if (!fs.existsSync(geminiCommandsRoot)) return [];
|
||||
const out = [];
|
||||
function walk(dir) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full);
|
||||
else if (entry.isFile()) out.push(path.relative(geminiCommandsRoot, full));
|
||||
}
|
||||
}
|
||||
walk(geminiCommandsRoot);
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
function runInstall(...args) {
|
||||
return captureConsole(() => install(...args));
|
||||
}
|
||||
|
||||
test('global install populates HOME/.gemini/commands/gsd', () => {
|
||||
runInstall(true, 'gemini');
|
||||
const globalCmds = path.join(tmpHome, '.gemini', 'commands', 'gsd');
|
||||
const files = listCommandFiles(globalCmds);
|
||||
assert.ok(
|
||||
files.length > 0,
|
||||
'global install must populate HOME/.gemini/commands/gsd'
|
||||
);
|
||||
});
|
||||
|
||||
test('local install after global does NOT populate PROJECT/.gemini/commands/gsd (avoids /gsd:* namespace conflict)', () => {
|
||||
// Step 1: global install
|
||||
runInstall(true, 'gemini');
|
||||
const globalCmds = path.join(tmpHome, '.gemini', 'commands', 'gsd');
|
||||
const globalFiles = listCommandFiles(globalCmds);
|
||||
assert.ok(globalFiles.length > 0, 'precondition: global install must succeed');
|
||||
|
||||
// Step 2: local install in a temp project
|
||||
process.chdir(tmpProject);
|
||||
const { stdout } = runInstall(false, 'gemini');
|
||||
assert.match(
|
||||
stdout,
|
||||
/Skipping commands\/gsd\/ for local install/,
|
||||
'local install must explain why it skips duplicate Gemini commands'
|
||||
);
|
||||
|
||||
// Assertion: the local commands/gsd/ directory must NOT exist (or must
|
||||
// be empty) so Gemini's conflict detection has nothing to rename. The
|
||||
// fix may either skip the directory entirely (preferred — no leftover
|
||||
// file system noise) or create an empty directory (acceptable but odd).
|
||||
const localCmds = path.join(tmpProject, '.gemini', 'commands', 'gsd');
|
||||
const localFiles = listCommandFiles(localCmds);
|
||||
assert.equal(
|
||||
localFiles.length,
|
||||
0,
|
||||
`local install must skip commands/gsd/ when global already exists; ` +
|
||||
`found ${localFiles.length} duplicate command file(s) at ${localCmds}`
|
||||
);
|
||||
});
|
||||
|
||||
test('local install with NO existing global GSD does still populate PROJECT/.gemini/commands/gsd', () => {
|
||||
// No global install first — local should proceed normally so users who
|
||||
// only ever run --local still get GSD commands in their project.
|
||||
process.chdir(tmpProject);
|
||||
runInstall(false, 'gemini');
|
||||
|
||||
const localCmds = path.join(tmpProject, '.gemini', 'commands', 'gsd');
|
||||
const localFiles = listCommandFiles(localCmds);
|
||||
assert.ok(
|
||||
localFiles.length > 0,
|
||||
`local-only install must populate PROJECT/.gemini/commands/gsd; ` +
|
||||
`found ${localFiles.length} files at ${localCmds}`
|
||||
);
|
||||
});
|
||||
|
||||
test('local install when HOME has hand-dropped overrides UNDER commands/gsd/ (but no full GSD) still populates locally', () => {
|
||||
// CR #3041 regression: the previous detection was
|
||||
// `fs.readdirSync(homeGeminiGsd).length > 0` which would skip the
|
||||
// local install for a user who manually dropped a single override
|
||||
// command at ~/.gemini/commands/gsd/<thing>.toml without ever
|
||||
// running --gemini --global. The fix narrows detection to require
|
||||
// at least 3 canonical GSD command files (help.toml, progress.toml,
|
||||
// new-project.toml) — a marker that's structurally impossible to
|
||||
// produce by accident.
|
||||
const homeGsdDir = path.join(tmpHome, '.gemini', 'commands', 'gsd');
|
||||
fs.mkdirSync(homeGsdDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(homeGsdDir, 'my-override.toml'),
|
||||
'description = "user override"\nprompt = "..."\n'
|
||||
);
|
||||
|
||||
process.chdir(tmpProject);
|
||||
runInstall(false, 'gemini');
|
||||
|
||||
const localCmds = path.join(tmpProject, '.gemini', 'commands', 'gsd');
|
||||
const localFiles = listCommandFiles(localCmds);
|
||||
assert.ok(
|
||||
localFiles.length > 0,
|
||||
`local install must proceed when HOME/.gemini/commands/gsd contains ` +
|
||||
`only user overrides (not the full GSD canary set); ` +
|
||||
`found ${localFiles.length} files at ${localCmds}`
|
||||
);
|
||||
});
|
||||
|
||||
test('local install when HOME/.gemini exists but commands/gsd is absent (non-GSD Gemini user) still populates locally', () => {
|
||||
// Simulate a user who has Gemini configured but never installed GSD
|
||||
// globally. ~/.gemini/ exists with unrelated content; ~/.gemini/commands/
|
||||
// may or may not exist with non-gsd subdirectories. Local install must
|
||||
// still proceed because no GSD-managed user-scope directory is present.
|
||||
fs.mkdirSync(path.join(tmpHome, '.gemini', 'commands', 'someone-else'), {
|
||||
recursive: true,
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(tmpHome, '.gemini', 'commands', 'someone-else', 'foo.toml'),
|
||||
'description = "user command"\nprompt = "..."\n'
|
||||
);
|
||||
|
||||
process.chdir(tmpProject);
|
||||
runInstall(false, 'gemini');
|
||||
|
||||
const localCmds = path.join(tmpProject, '.gemini', 'commands', 'gsd');
|
||||
const localFiles = listCommandFiles(localCmds);
|
||||
assert.ok(
|
||||
localFiles.length > 0,
|
||||
`local install must proceed when no GSD-managed user-scope directory ` +
|
||||
`exists, even if other Gemini commands are present at the user scope`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,264 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Bug #3181: `resolveNodeRunner()` bakes versioned Homebrew Cellar paths
|
||||
* (e.g. `/usr/local/Cellar/node/25.8.1/bin/node`) into hook commands in
|
||||
* `~/.claude/settings.json`. After `brew upgrade node` the Cellar binary
|
||||
* fails with `dyld: Library not loaded` because shared libraries have
|
||||
* changed SOVERSION.
|
||||
*
|
||||
* Fix: prefer the stable Homebrew symlinks (`/usr/local/bin/node` for Intel
|
||||
* Macs, `/opt/homebrew/bin/node` for Apple Silicon) when a Cellar path is
|
||||
* detected. Non-Homebrew paths (NVM, system node, Windows, etc.) are
|
||||
* returned unchanged.
|
||||
*
|
||||
* Also: `rewriteLegacyManagedNodeHookCommands()` must normalize Cellar paths
|
||||
* baked into existing hook commands so reinstall doesn't re-bake them.
|
||||
*
|
||||
* All assertions go against exported function return values — no source-grep.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
|
||||
const INSTALL = require(path.join(__dirname, '..', 'bin', 'install.js'));
|
||||
const { normalizeNodePath, resolveNodeRunner, rewriteLegacyManagedNodeHookCommands } = INSTALL;
|
||||
|
||||
// ─── normalizeNodePath ────────────────────────────────────────────────────────
|
||||
|
||||
describe('Bug #3181: normalizeNodePath — exported as a function', () => {
|
||||
test('normalizeNodePath is exported', () => {
|
||||
assert.equal(typeof normalizeNodePath, 'function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bug #3181: normalizeNodePath — Intel Homebrew Cellar paths → /usr/local/bin/node', () => {
|
||||
test('simple versioned Intel Cellar path', () => {
|
||||
const result = normalizeNodePath('/usr/local/Cellar/node/25.8.1/bin/node');
|
||||
assert.equal(result, '/usr/local/bin/node');
|
||||
});
|
||||
|
||||
test('Intel Cellar path with long semver', () => {
|
||||
const result = normalizeNodePath('/usr/local/Cellar/node/20.11.0/bin/node');
|
||||
assert.equal(result, '/usr/local/bin/node');
|
||||
});
|
||||
|
||||
test('Intel Cellar path with prerelease version segment', () => {
|
||||
const result = normalizeNodePath('/usr/local/Cellar/node/22.0.0-rc.1/bin/node');
|
||||
assert.equal(result, '/usr/local/bin/node');
|
||||
});
|
||||
|
||||
test('Intel versioned formula Cellar path (node@20) maps to stable symlink', () => {
|
||||
const result = normalizeNodePath('/usr/local/Cellar/node@20/20.11.0/bin/node');
|
||||
assert.equal(result, '/usr/local/bin/node');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bug #3181: normalizeNodePath — Apple Silicon Homebrew Cellar paths → /opt/homebrew/bin/node', () => {
|
||||
test('simple versioned Apple Silicon Cellar path', () => {
|
||||
const result = normalizeNodePath('/opt/homebrew/Cellar/node/25.8.1/bin/node');
|
||||
assert.equal(result, '/opt/homebrew/bin/node');
|
||||
});
|
||||
|
||||
test('Apple Silicon Cellar path with another version', () => {
|
||||
const result = normalizeNodePath('/opt/homebrew/Cellar/node/18.20.4/bin/node');
|
||||
assert.equal(result, '/opt/homebrew/bin/node');
|
||||
});
|
||||
|
||||
test('Apple Silicon versioned formula Cellar path (node@18) maps to stable symlink', () => {
|
||||
const result = normalizeNodePath('/opt/homebrew/Cellar/node@18/18.20.4/bin/node');
|
||||
assert.equal(result, '/opt/homebrew/bin/node');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bug #3181: normalizeNodePath — non-Homebrew paths are returned unchanged', () => {
|
||||
test('NVM path is unchanged', () => {
|
||||
const nvm = '/Users/dev/.nvm/versions/node/v20.11.0/bin/node';
|
||||
assert.equal(normalizeNodePath(nvm), nvm);
|
||||
});
|
||||
|
||||
test('already-stable Intel Homebrew symlink is unchanged', () => {
|
||||
assert.equal(normalizeNodePath('/usr/local/bin/node'), '/usr/local/bin/node');
|
||||
});
|
||||
|
||||
test('already-stable Apple Silicon Homebrew symlink is unchanged', () => {
|
||||
assert.equal(normalizeNodePath('/opt/homebrew/bin/node'), '/opt/homebrew/bin/node');
|
||||
});
|
||||
|
||||
test('system node (/usr/bin/node) is unchanged', () => {
|
||||
assert.equal(normalizeNodePath('/usr/bin/node'), '/usr/bin/node');
|
||||
});
|
||||
|
||||
test('Windows path is unchanged', () => {
|
||||
const win = 'C:\\Program Files\\nodejs\\node.exe';
|
||||
assert.equal(normalizeNodePath(win), win);
|
||||
});
|
||||
|
||||
test('empty string is returned as-is', () => {
|
||||
assert.equal(normalizeNodePath(''), '');
|
||||
});
|
||||
|
||||
test('null is returned as-is', () => {
|
||||
assert.equal(normalizeNodePath(null), null);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolveNodeRunner ────────────────────────────────────────────────────────
|
||||
|
||||
describe('Bug #3181: resolveNodeRunner — maps Cellar execPath to stable symlink', () => {
|
||||
test('Intel Cellar execPath → stable symlink quoted token', () => {
|
||||
const orig = process.execPath;
|
||||
try {
|
||||
Object.defineProperty(process, 'execPath', {
|
||||
value: '/usr/local/Cellar/node/25.8.1/bin/node',
|
||||
configurable: true,
|
||||
});
|
||||
const runner = resolveNodeRunner();
|
||||
assert.equal(runner, '"/usr/local/bin/node"',
|
||||
`expected stable Intel symlink, got: ${runner}`);
|
||||
} finally {
|
||||
Object.defineProperty(process, 'execPath', { value: orig, configurable: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('Apple Silicon Cellar execPath → stable symlink quoted token', () => {
|
||||
const orig = process.execPath;
|
||||
try {
|
||||
Object.defineProperty(process, 'execPath', {
|
||||
value: '/opt/homebrew/Cellar/node/25.8.1/bin/node',
|
||||
configurable: true,
|
||||
});
|
||||
const runner = resolveNodeRunner();
|
||||
assert.equal(runner, '"/opt/homebrew/bin/node"',
|
||||
`expected stable Apple Silicon symlink, got: ${runner}`);
|
||||
} finally {
|
||||
Object.defineProperty(process, 'execPath', { value: orig, configurable: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('non-Homebrew execPath is returned as a quoted absolute path unchanged', () => {
|
||||
const orig = process.execPath;
|
||||
const nvmPath = '/Users/dev/.nvm/versions/node/v20.11.0/bin/node';
|
||||
try {
|
||||
Object.defineProperty(process, 'execPath', { value: nvmPath, configurable: true });
|
||||
const runner = resolveNodeRunner();
|
||||
assert.equal(runner, JSON.stringify(nvmPath));
|
||||
} finally {
|
||||
Object.defineProperty(process, 'execPath', { value: orig, configurable: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('returns null when execPath is empty (existing null-guard is preserved)', () => {
|
||||
const orig = process.execPath;
|
||||
try {
|
||||
Object.defineProperty(process, 'execPath', { value: '', configurable: true });
|
||||
assert.equal(resolveNodeRunner(), null);
|
||||
} finally {
|
||||
Object.defineProperty(process, 'execPath', { value: orig, configurable: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── rewriteLegacyManagedNodeHookCommands — Cellar runner rewrite ─────────────
|
||||
|
||||
describe('Bug #3181: rewriteLegacyManagedNodeHookCommands — rewrites baked Cellar runner to stable symlink', () => {
|
||||
test('Intel Cellar runner in a managed hook is rewritten to the stable symlink', () => {
|
||||
const settings = {
|
||||
hooks: {
|
||||
SessionStart: [{
|
||||
hooks: [{
|
||||
type: 'command',
|
||||
command: '"/usr/local/Cellar/node/25.8.1/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"',
|
||||
}],
|
||||
}],
|
||||
},
|
||||
};
|
||||
const runner = '"/usr/local/bin/node"';
|
||||
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner);
|
||||
assert.equal(changed, true, 'expected rewrite to occur');
|
||||
assert.equal(
|
||||
settings.hooks.SessionStart[0].hooks[0].command,
|
||||
'"/usr/local/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"',
|
||||
);
|
||||
});
|
||||
|
||||
test('Apple Silicon Cellar runner in a managed hook is rewritten to the stable symlink', () => {
|
||||
const settings = {
|
||||
hooks: {
|
||||
SessionStart: [{
|
||||
hooks: [{
|
||||
type: 'command',
|
||||
command: '"/opt/homebrew/Cellar/node/25.8.1/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"',
|
||||
}],
|
||||
}],
|
||||
},
|
||||
};
|
||||
const runner = '"/opt/homebrew/bin/node"';
|
||||
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner);
|
||||
assert.equal(changed, true, 'expected rewrite to occur');
|
||||
assert.equal(
|
||||
settings.hooks.SessionStart[0].hooks[0].command,
|
||||
'"/opt/homebrew/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"',
|
||||
);
|
||||
});
|
||||
|
||||
test('a hook already using the stable runner is NOT rewritten (no churn)', () => {
|
||||
const settings = {
|
||||
hooks: {
|
||||
SessionStart: [{
|
||||
hooks: [{
|
||||
type: 'command',
|
||||
command: '"/usr/local/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"',
|
||||
}],
|
||||
}],
|
||||
},
|
||||
};
|
||||
const runner = '"/usr/local/bin/node"';
|
||||
const before = settings.hooks.SessionStart[0].hooks[0].command;
|
||||
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner);
|
||||
assert.equal(changed, false, 'already-stable entry must not be touched');
|
||||
assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before);
|
||||
});
|
||||
|
||||
test('a user hook using a Cellar runner but an unmanaged filename is NOT rewritten', () => {
|
||||
const settings = {
|
||||
hooks: {
|
||||
SessionStart: [{
|
||||
hooks: [{
|
||||
type: 'command',
|
||||
command: '"/usr/local/Cellar/node/25.8.1/bin/node" "/Users/x/my-custom-hook.js"',
|
||||
}],
|
||||
}],
|
||||
},
|
||||
};
|
||||
const runner = '"/usr/local/bin/node"';
|
||||
const before = settings.hooks.SessionStart[0].hooks[0].command;
|
||||
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner);
|
||||
assert.equal(changed, false, 'unmanaged hooks with Cellar runner must not be touched');
|
||||
assert.equal(settings.hooks.SessionStart[0].hooks[0].command, before);
|
||||
});
|
||||
|
||||
// Existing bare-node rewrite still works alongside the new Cellar rewrite
|
||||
test('bare `node` managed hook is still rewritten (existing #2979 behaviour preserved)', () => {
|
||||
const settings = {
|
||||
hooks: {
|
||||
SessionStart: [{
|
||||
hooks: [{
|
||||
type: 'command',
|
||||
command: 'node "/Users/x/.gemini/hooks/gsd-check-update.js"',
|
||||
}],
|
||||
}],
|
||||
},
|
||||
};
|
||||
const runner = '"/usr/local/bin/node"';
|
||||
const changed = rewriteLegacyManagedNodeHookCommands(settings, runner);
|
||||
assert.equal(changed, true);
|
||||
assert.equal(
|
||||
settings.hooks.SessionStart[0].hooks[0].command,
|
||||
'"/usr/local/bin/node" "/Users/x/.gemini/hooks/gsd-check-update.js"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,510 +0,0 @@
|
||||
/**
|
||||
* Regression: issue #3245 — Codex install rejects valid TOML floats.
|
||||
*
|
||||
* Two defects, two fixes:
|
||||
*
|
||||
* Defect 1 — parseTomlValue rejects TOML floats (e.g. tool_timeout_sec = 20.0).
|
||||
* Codex CLI's serde schema requires f64 for tool_timeout_sec / startup_timeout_sec
|
||||
* (integers fail with "invalid type: integer"). GSD's strict-integer-only parser
|
||||
* was the inverse of what Codex requires — any float triggers the rejection branch.
|
||||
* Fix: extend parseTomlValue to accept TOML 1.0 float literals and return them as
|
||||
* JS Number. The merged config.toml preserves the float form verbatim so
|
||||
* round-trip writes don't coerce 20.0 → 20.
|
||||
*
|
||||
* Defect 2 — Partial rollback leaves install in hybrid state.
|
||||
* restoreCodexSnapshot only knew about config.toml, but skills/, agents/, and VERSION
|
||||
* are written earlier in the install sequence. A post-install validation failure
|
||||
* aborts with new agent text on disk, config.toml reverted, and .tmp files
|
||||
* potentially orphaned.
|
||||
* Fix: capture pre-install state of skills/, agents/, and VERSION before any
|
||||
* Codex-specific mutation, and extend the rollback to cover all of them.
|
||||
*/
|
||||
|
||||
// GSD_TEST_MODE must be set before require('../bin/install.js') so the module
|
||||
// skips the main CLI entry point and exports its internals.
|
||||
const previousGsdTestMode = process.env.GSD_TEST_MODE;
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe, before, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { execFileSync } = require('child_process');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const { parseTomlToObject, validateCodexConfigSchema, install } = require('../bin/install.js');
|
||||
const installModule = require('../bin/install.js');
|
||||
|
||||
if (previousGsdTestMode === undefined) {
|
||||
delete process.env.GSD_TEST_MODE;
|
||||
} else {
|
||||
process.env.GSD_TEST_MODE = previousGsdTestMode;
|
||||
}
|
||||
|
||||
// Ensure hooks/dist/ is populated — mirrors the pattern used by codex-config.test.cjs.
|
||||
const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist');
|
||||
const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
||||
before(() => {
|
||||
if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) {
|
||||
execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' });
|
||||
}
|
||||
});
|
||||
|
||||
function runCodexInstall(codexHome) {
|
||||
const previousCodexHome = process.env.CODEX_HOME;
|
||||
const previousCwd = process.cwd();
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
try {
|
||||
process.chdir(path.join(__dirname, '..'));
|
||||
return install(true, 'codex');
|
||||
} finally {
|
||||
process.chdir(previousCwd);
|
||||
if (previousCodexHome === undefined) {
|
||||
delete process.env.CODEX_HOME;
|
||||
} else {
|
||||
process.env.CODEX_HOME = previousCodexHome;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writeCodexConfig(codexHome, content) {
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
fs.writeFileSync(path.join(codexHome, 'config.toml'), content, 'utf8');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Defect 1 — parseTomlValue must accept TOML floats
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('#3245 — parseTomlToObject accepts TOML floats', () => {
|
||||
test('parses bare decimal float (20.0)', () => {
|
||||
const content = [
|
||||
'tool_timeout_sec = 20.0',
|
||||
'',
|
||||
].join('\n');
|
||||
const parsed = parseTomlToObject(content);
|
||||
assert.strictEqual(typeof parsed.tool_timeout_sec, 'number',
|
||||
'tool_timeout_sec should be a JS number');
|
||||
assert.strictEqual(parsed.tool_timeout_sec, 20.0,
|
||||
'value must equal 20.0');
|
||||
});
|
||||
|
||||
test('parses startup_timeout_sec = 60.0', () => {
|
||||
const content = [
|
||||
'startup_timeout_sec = 60.0',
|
||||
'',
|
||||
].join('\n');
|
||||
const parsed = parseTomlToObject(content);
|
||||
assert.strictEqual(parsed.startup_timeout_sec, 60.0);
|
||||
});
|
||||
|
||||
test('parses positive exponent notation (1e10)', () => {
|
||||
const content = [
|
||||
'x = 1e10',
|
||||
'',
|
||||
].join('\n');
|
||||
const parsed = parseTomlToObject(content);
|
||||
assert.strictEqual(parsed.x, 1e10);
|
||||
});
|
||||
|
||||
test('parses negative exponent (1.5e-3)', () => {
|
||||
const content = [
|
||||
'x = 1.5e-3',
|
||||
'',
|
||||
].join('\n');
|
||||
const parsed = parseTomlToObject(content);
|
||||
assert.ok(Math.abs(parsed.x - 1.5e-3) < 1e-15, 'must be approximately 1.5e-3');
|
||||
});
|
||||
|
||||
test('parses signed positive float (+1.0)', () => {
|
||||
const content = [
|
||||
'x = +1.0',
|
||||
'',
|
||||
].join('\n');
|
||||
const parsed = parseTomlToObject(content);
|
||||
assert.strictEqual(parsed.x, 1.0);
|
||||
});
|
||||
|
||||
test('parses signed negative float (-0.5)', () => {
|
||||
const content = [
|
||||
'x = -0.5',
|
||||
'',
|
||||
].join('\n');
|
||||
const parsed = parseTomlToObject(content);
|
||||
assert.strictEqual(parsed.x, -0.5);
|
||||
});
|
||||
|
||||
test('parses float with underscore separators (1_000.0)', () => {
|
||||
const content = [
|
||||
'x = 1_000.0',
|
||||
'',
|
||||
].join('\n');
|
||||
const parsed = parseTomlToObject(content);
|
||||
assert.strictEqual(parsed.x, 1000.0);
|
||||
});
|
||||
|
||||
test('integer (no decimal) still parses as integer', () => {
|
||||
const content = [
|
||||
'x = 42',
|
||||
'',
|
||||
].join('\n');
|
||||
const parsed = parseTomlToObject(content);
|
||||
assert.strictEqual(parsed.x, 42);
|
||||
});
|
||||
|
||||
test('still rejects bare date (1979-05-27)', () => {
|
||||
const content = [
|
||||
'x = 1979-05-27',
|
||||
'',
|
||||
].join('\n');
|
||||
assert.throws(
|
||||
() => parseTomlToObject(content),
|
||||
/unsupported TOML value/,
|
||||
'date literals must remain unsupported'
|
||||
);
|
||||
});
|
||||
|
||||
test('still rejects bare time (07:32:00)', () => {
|
||||
const content = [
|
||||
'x = 07:32:00',
|
||||
'',
|
||||
].join('\n');
|
||||
// With leading-zero rejection (CR4 fix) the parser stops at `0`, and
|
||||
// `7:32:00` is "trailing bytes". Either error form is acceptable — the
|
||||
// key invariant is that time literals are never silently accepted.
|
||||
assert.throws(
|
||||
() => parseTomlToObject(content),
|
||||
/unsupported TOML value|trailing bytes/,
|
||||
'time literals must remain unsupported'
|
||||
);
|
||||
});
|
||||
|
||||
test('still rejects hex literal (0x1A)', () => {
|
||||
const content = [
|
||||
'x = 0x1A',
|
||||
'',
|
||||
].join('\n');
|
||||
// 0 is parsed, then 'x1A' is trailing garbage — rejected with "trailing bytes"
|
||||
// or "unsupported value" depending on where the parser catches it.
|
||||
assert.throws(
|
||||
() => parseTomlToObject(content),
|
||||
/trailing bytes|unsupported (TOML value|value)/,
|
||||
'hex literals must remain unsupported'
|
||||
);
|
||||
});
|
||||
|
||||
test('validateCodexConfigSchema passes a config with tool_timeout_sec = 20.0', () => {
|
||||
const content = [
|
||||
'[model]',
|
||||
'name = "o3"',
|
||||
'',
|
||||
'tool_timeout_sec = 20.0',
|
||||
'startup_timeout_sec = 60.0',
|
||||
'',
|
||||
].join('\n');
|
||||
const result = validateCodexConfigSchema(content);
|
||||
assert.strictEqual(result.ok, true,
|
||||
'schema validation must pass for a config containing TOML floats: ' + result.reason);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Defect 1 — full install must succeed and preserve float verbatim
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// concurrency: false — drives the live install pipeline (shared CODEX_HOME env,
|
||||
// process.chdir). Serialise to prevent stray mutations across parallel siblings.
|
||||
describe('#3245 — install succeeds with TOML float in pre-existing config', { concurrency: false }, () => {
|
||||
let tmpDir;
|
||||
let codexHome;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3245-float-'));
|
||||
codexHome = path.join(tmpDir, 'codex-home');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('install completes when config.toml contains tool_timeout_sec = 20.0', () => {
|
||||
// Floats at the root level (before any table header) — this is where Codex
|
||||
// CLI reads tool_timeout_sec / startup_timeout_sec according to its serde schema.
|
||||
const preInstall = [
|
||||
'tool_timeout_sec = 20.0',
|
||||
'startup_timeout_sec = 60.0',
|
||||
'',
|
||||
'[model]',
|
||||
'name = "o3"',
|
||||
'',
|
||||
].join('\n');
|
||||
writeCodexConfig(codexHome, preInstall);
|
||||
|
||||
// Must not throw — pre-#3245 this threw "unsupported TOML value … floats … not supported".
|
||||
assert.doesNotThrow(
|
||||
() => runCodexInstall(codexHome),
|
||||
'install must not throw when config.toml contains TOML floats'
|
||||
);
|
||||
|
||||
// The merged config.toml must still contain the float values at root scope.
|
||||
const after = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
|
||||
const parsed = parseTomlToObject(after);
|
||||
assert.strictEqual(parsed.tool_timeout_sec, 20.0,
|
||||
'tool_timeout_sec must be preserved as a number after install');
|
||||
assert.strictEqual(parsed.startup_timeout_sec, 60.0,
|
||||
'startup_timeout_sec must be preserved as a number after install');
|
||||
});
|
||||
|
||||
test('post-install config round-trips tool_timeout_sec as numeric 20', () => {
|
||||
const preInstall = [
|
||||
'tool_timeout_sec = 20.0',
|
||||
'',
|
||||
].join('\n');
|
||||
writeCodexConfig(codexHome, preInstall);
|
||||
|
||||
runCodexInstall(codexHome);
|
||||
|
||||
const after = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
|
||||
// The value must survive round-trip as a float-compatible representation.
|
||||
// Parse structurally — don't grep for the literal string "20.0".
|
||||
const parsed = parseTomlToObject(after);
|
||||
assert.strictEqual(parsed.tool_timeout_sec, 20,
|
||||
'tool_timeout_sec must round-trip as numeric 20 (=== 20.0 in JS)');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CR round-4 finding — TOML 1.0 disallows leading zeros in integer part
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// TOML 1.0 §2: integer literals follow decimal-integer rules, which disallow
|
||||
// leading zeros except the value `0` itself. `01`, `01.5`, `00e2`, `+01.0`
|
||||
// are therefore invalid. The `parseTomlValue` integer-part regex is tightened
|
||||
// from `\d(?:_?\d)*` to `(0|[1-9](?:_?\d)*)`.
|
||||
|
||||
describe('#3245 CR4 — parseTomlValue rejects leading zeros in float integer part', () => {
|
||||
function parseValue(raw) {
|
||||
// Wrap in a minimal TOML assignment so parseTomlToObject drives the test.
|
||||
return parseTomlToObject(`x = ${raw}`).x;
|
||||
}
|
||||
|
||||
function assertRejects(raw, label) {
|
||||
let threw = false;
|
||||
try { parseValue(raw); } catch (_) { threw = true; }
|
||||
assert.strictEqual(threw, true, `expected rejection for ${label}: ${raw}`);
|
||||
}
|
||||
|
||||
function assertAccepts(raw, expected, label) {
|
||||
let val;
|
||||
let threw = false;
|
||||
try { val = parseValue(raw); } catch (e) { threw = true; }
|
||||
assert.strictEqual(threw, false, `expected acceptance for ${label}: ${raw}`);
|
||||
if (expected !== undefined) {
|
||||
assert.ok(Math.abs(val - expected) < 1e-12, `${label}: expected ${expected}, got ${val}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- rejection cases: leading zeros in the integer part ---
|
||||
|
||||
test('rejects 01 (leading zero on bare integer)', () => assertRejects('01', '01'));
|
||||
test('rejects 00 (double-zero bare integer)', () => assertRejects('00', '00'));
|
||||
test('rejects 01.5 (leading zero before decimal point)', () => assertRejects('01.5', '01.5'));
|
||||
test('rejects 00.5 (double-zero before decimal)', () => assertRejects('00.5', '00.5'));
|
||||
test('rejects +01 (leading zero with sign)', () => assertRejects('+01', '+01'));
|
||||
test('rejects -01 (negative leading zero)', () => assertRejects('-01', '-01'));
|
||||
test('rejects 00e2 (leading zero with exponent)', () => assertRejects('00e2', '00e2'));
|
||||
test('rejects +01.0 (leading zero in positive float)', () => assertRejects('+01.0', '+01.0'));
|
||||
test('rejects -01.0 (leading zero in negative float)', () => assertRejects('-01.0', '-01.0'));
|
||||
test('rejects 01.5e10 (leading zero, decimal, and exponent)', () => assertRejects('01.5e10', '01.5e10'));
|
||||
|
||||
// --- acceptance cases: valid TOML 1.0 numeric forms ---
|
||||
|
||||
test('accepts 0 (single zero)', () => assertAccepts('0', 0, 'single zero'));
|
||||
test('accepts 0.5 (zero before decimal)', () => assertAccepts('0.5', 0.5, 'zero.decimal'));
|
||||
test('accepts 0.0 (zero.zero)', () => assertAccepts('0.0', 0.0, 'zero.zero'));
|
||||
test('accepts 0e1 (zero with exponent)', () => assertAccepts('0e1', 0, '0e1'));
|
||||
test('accepts +0.5 (positive zero-decimal)', () => assertAccepts('+0.5', 0.5, '+0.5'));
|
||||
test('accepts -0.5 (negative zero-decimal)', () => assertAccepts('-0.5', -0.5, '-0.5'));
|
||||
test('accepts 1 (single non-zero digit)', () => assertAccepts('1', 1, '1'));
|
||||
test('accepts 12 (two digits)', () => assertAccepts('12', 12, '12'));
|
||||
test('accepts 1.5 (simple float)', () => assertAccepts('1.5', 1.5, '1.5'));
|
||||
test('accepts 1_000 (underscored integer)', () => assertAccepts('1_000', 1000, '1_000'));
|
||||
test('accepts 1_000.5 (underscored float)', () => assertAccepts('1_000.5', 1000.5, '1_000.5'));
|
||||
test('accepts +1.5 (positive float)', () => assertAccepts('+1.5', 1.5, '+1.5'));
|
||||
test('accepts -2.0 (negative float)', () => assertAccepts('-2.0', -2.0, '-2.0'));
|
||||
test('accepts 1.5e-3 (float with negative exponent)', () => assertAccepts('1.5e-3', 1.5e-3, '1.5e-3'));
|
||||
test('accepts 1.05e10 (fractional part may start with zero)', () => assertAccepts('1.05e10', 1.05e10, '1.05e10'));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Defect 2 — idempotent rollback covers skills, agents, VERSION
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// concurrency: false — patches module.exports.__codexSchemaValidator and drives
|
||||
// the install pipeline. Serialise to prevent cross-test pollution.
|
||||
describe('#3245 — idempotent rollback reverts skills/, agents/, and VERSION', { concurrency: false }, () => {
|
||||
let tmpDir;
|
||||
let codexHome;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3245-rollback-'));
|
||||
codexHome = path.join(tmpDir, 'codex-home');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete installModule.__codexSchemaValidator;
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('validation failure rolls back skills/, agents/, and VERSION to pre-install state', () => {
|
||||
// Start from a clean codexHome with no pre-existing GSD content — the dirs
|
||||
// do not exist yet. After a failed install they must be absent (or contain
|
||||
// only what was there before, i.e. nothing).
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
|
||||
// Force schema validation to fail so we can observe the rollback without
|
||||
// needing a genuinely broken config.
|
||||
installModule.__codexSchemaValidator = () => ({
|
||||
ok: false,
|
||||
reason: 'simulated failure for #3245 rollback test',
|
||||
});
|
||||
|
||||
let threw = false;
|
||||
try {
|
||||
runCodexInstall(codexHome);
|
||||
} catch (_) {
|
||||
threw = true;
|
||||
}
|
||||
assert.strictEqual(threw, true, 'install must throw when validation fails');
|
||||
|
||||
// skills/ — GSD writes gsd-* subdirs here. All must be absent after rollback.
|
||||
const skillsDir = path.join(codexHome, 'skills');
|
||||
if (fs.existsSync(skillsDir)) {
|
||||
const gsdSkills = fs.readdirSync(skillsDir, { withFileTypes: true })
|
||||
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
|
||||
assert.strictEqual(
|
||||
gsdSkills.length,
|
||||
0,
|
||||
'rollback must remove all gsd-* skill directories: ' + gsdSkills.map(e => e.name).join(', ')
|
||||
);
|
||||
}
|
||||
|
||||
// agents/ — GSD writes gsd-*.md and gsd-*.toml here. All must be absent.
|
||||
// Not the shared listAgentFiles() helper: reads the INSTALLED Codex dest
|
||||
// dir and is .toml-inclusive, so its semantics differ from the source roster.
|
||||
const agentsDir = path.join(codexHome, 'agents');
|
||||
if (fs.existsSync(agentsDir)) {
|
||||
const gsdAgents = fs.readdirSync(agentsDir)
|
||||
.filter(f => f.startsWith('gsd-') && (f.endsWith('.md') || f.endsWith('.toml')));
|
||||
assert.strictEqual(
|
||||
gsdAgents.length,
|
||||
0,
|
||||
'rollback must remove all gsd-* agent files: ' + gsdAgents.join(', ')
|
||||
);
|
||||
}
|
||||
|
||||
// VERSION — GSD writes gsd-core/VERSION. Must be absent (wasn't there before).
|
||||
const versionPath = path.join(codexHome, 'gsd-core', 'VERSION');
|
||||
assert.strictEqual(
|
||||
fs.existsSync(versionPath),
|
||||
false,
|
||||
'rollback must remove the VERSION file written during install'
|
||||
);
|
||||
});
|
||||
|
||||
test('rollback is safe when fired before any snapshots were captured (very early failure)', () => {
|
||||
// If the validator is injected before ANY install writes happen, the rollback
|
||||
// must not throw — it should be idempotent when nothing was written yet.
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
|
||||
installModule.__codexSchemaValidator = () => ({
|
||||
ok: false,
|
||||
reason: 'very early simulated failure',
|
||||
});
|
||||
|
||||
// The install must throw (validation failure), but the rollback that runs
|
||||
// internally must not throw — it must be idempotent when nothing was written.
|
||||
let threw = false;
|
||||
try {
|
||||
runCodexInstall(codexHome);
|
||||
} catch (_) {
|
||||
threw = true;
|
||||
}
|
||||
assert.strictEqual(threw, true, 'install must throw when validation fails (very early failure)');
|
||||
// Rollback removes all gsd-* skill dirs it wrote. Even if skills/ was
|
||||
// created during the install, no gsd-* dirs should survive after rollback.
|
||||
const skillsDir = path.join(codexHome, 'skills');
|
||||
const remainingGsdSkills = fs.existsSync(skillsDir)
|
||||
? fs.readdirSync(skillsDir, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory() && e.name.startsWith('gsd-'))
|
||||
.map((e) => e.name)
|
||||
: [];
|
||||
assert.deepStrictEqual(
|
||||
remainingGsdSkills,
|
||||
[],
|
||||
'rollback must remove all gsd-* skill dirs even when fired after minimal writes'
|
||||
);
|
||||
});
|
||||
|
||||
test('rollback does not remove pre-existing user skills that GSD did not write', () => {
|
||||
// If the user has a custom skill dir (not gsd-*) it must survive rollback.
|
||||
const skillsDir = path.join(codexHome, 'skills');
|
||||
const userSkill = path.join(skillsDir, 'my-custom-skill');
|
||||
fs.mkdirSync(userSkill, { recursive: true });
|
||||
fs.writeFileSync(path.join(userSkill, 'SKILL.md'), '# Custom\n', 'utf8');
|
||||
|
||||
installModule.__codexSchemaValidator = () => ({
|
||||
ok: false,
|
||||
reason: 'simulated failure — user skill must survive',
|
||||
});
|
||||
|
||||
let threw = false;
|
||||
try { runCodexInstall(codexHome); } catch (_) { threw = true; }
|
||||
assert.strictEqual(threw, true, 'expected runCodexInstall to throw under simulated validation failure (user-skill-survives scenario)');
|
||||
|
||||
assert.strictEqual(
|
||||
fs.existsSync(path.join(userSkill, 'SKILL.md')),
|
||||
true,
|
||||
'pre-existing non-gsd-* skill must survive rollback'
|
||||
);
|
||||
});
|
||||
|
||||
test('rollback removes orphaned atomic-write temp files', () => {
|
||||
// Any <file>.tmp-<pid>-<n> files created during aborted atomic writes
|
||||
// must be cleaned up by the rollback so targetDir is not left with stray
|
||||
// temp files consuming disk space.
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
|
||||
installModule.__codexSchemaValidator = () => ({
|
||||
ok: false,
|
||||
reason: 'simulated failure for temp-file cleanup test',
|
||||
});
|
||||
|
||||
let threw = false;
|
||||
try { runCodexInstall(codexHome); } catch (_) { threw = true; }
|
||||
assert.strictEqual(threw, true, 'expected runCodexInstall to throw under simulated validation failure (temp-file cleanup scenario)');
|
||||
|
||||
// Scan for any *.tmp-* files left in codexHome after rollback.
|
||||
const tmpPattern = /\.tmp-\d+-\d+$/;
|
||||
function findTmpFiles(dir) {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
const results = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...findTmpFiles(full));
|
||||
} else if (tmpPattern.test(entry.name)) {
|
||||
results.push(full);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
const stray = findTmpFiles(codexHome);
|
||||
assert.strictEqual(
|
||||
stray.length,
|
||||
0,
|
||||
'rollback must clean up orphaned atomic-write temp files: ' + stray.join(', ')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,282 +0,0 @@
|
||||
/**
|
||||
* Regression: issue #3285 — Codex install fails when config.toml contains
|
||||
* hooks.state entries.
|
||||
*
|
||||
* Root cause: validateCodexConfigSchema walks every `hooks.*` table section
|
||||
* and asserts array-of-tables (AoT) shape, without distinguishing the
|
||||
* `hooks.state.*` namespace (Codex-managed per-hook trust persistence, a
|
||||
* regular table) from `hooks.<EVENT>` (event handlers like SessionStart,
|
||||
* which DO require AoT shape via [[hooks.SessionStart]]).
|
||||
*
|
||||
* Fix: add a carve-out so that any table whose path starts with `hooks.state`
|
||||
* is validated as a regular table (not AoT). All `hooks.<EVENT>` paths still
|
||||
* require AoT.
|
||||
*/
|
||||
|
||||
// GSD_TEST_MODE must be set before require('../bin/install.js') so the module
|
||||
// skips the main CLI entry point and exports its internals.
|
||||
const previousGsdTestMode = process.env.GSD_TEST_MODE;
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const { validateCodexConfigSchema, install } = require('../bin/install.js');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
if (previousGsdTestMode === undefined) {
|
||||
delete process.env.GSD_TEST_MODE;
|
||||
} else {
|
||||
process.env.GSD_TEST_MODE = previousGsdTestMode;
|
||||
}
|
||||
|
||||
// Ensure hooks/dist/ is populated — mirrors the pattern used by codex-config.test.cjs.
|
||||
const { before, beforeEach, afterEach } = require('node:test');
|
||||
const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist');
|
||||
const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
||||
before(() => {
|
||||
if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) {
|
||||
execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { encoding: 'utf-8', stdio: 'pipe' });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validator unit tests (no install, just validateCodexConfigSchema)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('#3285 — validateCodexConfigSchema: hooks.state is a regular table (not AoT)', () => {
|
||||
test('bare [hooks.state] table header passes validation', () => {
|
||||
const content = [
|
||||
'[hooks.state]',
|
||||
'',
|
||||
].join('\n');
|
||||
const result = validateCodexConfigSchema(content);
|
||||
assert.strictEqual(result.ok, true,
|
||||
'bare [hooks.state] must be allowed (regular-table namespace): ' + result.reason);
|
||||
});
|
||||
|
||||
test('bare [hooks.state.<project-key>] table header passes validation', () => {
|
||||
// Mirrors the exact shape Codex CLI 0.130.0+ writes for per-hook trust entries.
|
||||
// The key contains slashes and colons — must be quoted in TOML.
|
||||
const content = [
|
||||
'[hooks.state]',
|
||||
'',
|
||||
"[hooks.state.'/home/user/.codex/hooks.json:pre_tool_use:0:0']",
|
||||
'enabled = true',
|
||||
'trusted_hash = "sha256:abc123"',
|
||||
'',
|
||||
].join('\n');
|
||||
const result = validateCodexConfigSchema(content);
|
||||
assert.strictEqual(result.ok, true,
|
||||
'bare [hooks.state.<key>] with trust fields must be allowed: ' + result.reason);
|
||||
});
|
||||
|
||||
test('hooks.state alongside [[hooks.SessionStart]] AoT both pass', () => {
|
||||
// The real-world fixture: user has both Codex trust state AND GSD-managed
|
||||
// event hooks in the same config.toml.
|
||||
const content = [
|
||||
'[hooks.state]',
|
||||
'',
|
||||
"[hooks.state.'/home/user/.codex/hooks.json:pre_tool_use:0:0']",
|
||||
'enabled = true',
|
||||
'trusted_hash = "sha256:abc123"',
|
||||
'',
|
||||
'[[hooks.SessionStart]]',
|
||||
'',
|
||||
'[[hooks.SessionStart.hooks]]',
|
||||
'type = "command"',
|
||||
'command = "/usr/local/bin/gsd-check-update"',
|
||||
'',
|
||||
].join('\n');
|
||||
const result = validateCodexConfigSchema(content);
|
||||
assert.strictEqual(result.ok, true,
|
||||
'mixed hooks.state (regular table) + [[hooks.SessionStart]] (AoT) must pass: ' + result.reason);
|
||||
});
|
||||
|
||||
test('[[hooks.SessionStart]] AoT still requires array-of-tables shape', () => {
|
||||
// Regression guard: the fix must NOT relax AoT requirements for event hooks.
|
||||
// [hooks.SessionStart] (single-bracket) must still fail.
|
||||
const content = [
|
||||
'[hooks.SessionStart]',
|
||||
'type = "command"',
|
||||
'command = "/some/command"',
|
||||
'',
|
||||
].join('\n');
|
||||
const result = validateCodexConfigSchema(content);
|
||||
assert.strictEqual(result.ok, false,
|
||||
'[hooks.SessionStart] bare table (not AoT) must still be rejected');
|
||||
assert.ok(
|
||||
result.reason.includes('hooks.SessionStart'),
|
||||
'rejection reason must mention hooks.SessionStart, got: ' + result.reason
|
||||
);
|
||||
});
|
||||
|
||||
test('hooks.state object in parsed structure does not trigger non-array rejection', () => {
|
||||
// The parsed-object check loops over Object.entries(parsed.hooks) and
|
||||
// asserts !Array.isArray(value) → error. hooks.state is an object, not
|
||||
// an array. The fix must skip hooks.state in that loop too.
|
||||
const content = [
|
||||
'[hooks.state]',
|
||||
'',
|
||||
"[hooks.state.'some-key']",
|
||||
'enabled = true',
|
||||
'trusted_hash = "sha256:deadbeef"',
|
||||
'',
|
||||
].join('\n');
|
||||
const result = validateCodexConfigSchema(content);
|
||||
assert.strictEqual(result.ok, true,
|
||||
'parsed hooks.state object must not trigger "hooks.state must be an array" rejection: ' + result.reason);
|
||||
});
|
||||
|
||||
test('multiple hooks.state sub-keys all pass validation', () => {
|
||||
const content = [
|
||||
'[hooks.state]',
|
||||
'',
|
||||
"[hooks.state.'/project/a/.codex/hooks.json:pre_tool_use:0:0']",
|
||||
'enabled = true',
|
||||
'trusted_hash = "sha256:aaa"',
|
||||
'',
|
||||
"[hooks.state.'/project/b/.codex/hooks.json:pre_tool_use:0:0']",
|
||||
'enabled = false',
|
||||
'trusted_hash = "sha256:bbb"',
|
||||
'',
|
||||
].join('\n');
|
||||
const result = validateCodexConfigSchema(content);
|
||||
assert.strictEqual(result.ok, true,
|
||||
'multiple hooks.state sub-keys must all pass: ' + result.reason);
|
||||
});
|
||||
|
||||
test('[[hooks.state]] AoT form is rejected', () => {
|
||||
// hooks.state must be a regular table — array-of-tables shape is invalid.
|
||||
const content = [
|
||||
'[[hooks.state]]',
|
||||
'enabled = true',
|
||||
'',
|
||||
].join('\n');
|
||||
const result = validateCodexConfigSchema(content);
|
||||
assert.strictEqual(result.ok, false,
|
||||
'[[hooks.state]] (AoT) must be rejected');
|
||||
assert.ok(
|
||||
result.reason.includes('hooks.state'),
|
||||
'rejection reason must mention hooks.state, got: ' + result.reason
|
||||
);
|
||||
});
|
||||
|
||||
test('[[hooks.state.foo]] AoT sub-key form is rejected', () => {
|
||||
// hooks.state.* sub-keys must be regular tables — AoT sub-key shape is invalid.
|
||||
const content = [
|
||||
'[[hooks.state.foo]]',
|
||||
'enabled = true',
|
||||
'',
|
||||
].join('\n');
|
||||
const result = validateCodexConfigSchema(content);
|
||||
assert.strictEqual(result.ok, false,
|
||||
'[[hooks.state.foo]] (AoT sub-key) must be rejected');
|
||||
assert.ok(
|
||||
result.reason.includes('hooks.state'),
|
||||
'rejection reason must mention hooks.state, got: ' + result.reason
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Full install integration test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('#3285 — install succeeds when config.toml contains hooks.state entries', { concurrency: false }, () => {
|
||||
let tmpDir;
|
||||
let codexHome;
|
||||
|
||||
function writeCodexConfig(content) {
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
fs.writeFileSync(path.join(codexHome, 'config.toml'), content, 'utf8');
|
||||
}
|
||||
|
||||
function runCodexInstall() {
|
||||
const previousCodexHome = process.env.CODEX_HOME;
|
||||
const previousCwd = process.cwd();
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
try {
|
||||
process.chdir(path.join(__dirname, '..'));
|
||||
return install(true, 'codex');
|
||||
} finally {
|
||||
process.chdir(previousCwd);
|
||||
if (previousCodexHome === undefined) {
|
||||
delete process.env.CODEX_HOME;
|
||||
} else {
|
||||
process.env.CODEX_HOME = previousCodexHome;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3285-'));
|
||||
codexHome = path.join(tmpDir, 'codex-home');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('install does not throw when config.toml contains hooks.state trust entries', () => {
|
||||
// This is the exact failure scenario reported in #3285.
|
||||
const preInstall = [
|
||||
'[hooks.state]',
|
||||
'',
|
||||
"[hooks.state.'/home/user/.codex/hooks.json:pre_tool_use:0:0']",
|
||||
'enabled = true',
|
||||
'trusted_hash = "sha256:abc123def456"',
|
||||
'',
|
||||
].join('\n');
|
||||
writeCodexConfig(preInstall);
|
||||
|
||||
assert.doesNotThrow(
|
||||
() => runCodexInstall(),
|
||||
'install must not throw when config.toml contains hooks.state trust entries'
|
||||
);
|
||||
});
|
||||
|
||||
test('hooks.state entries are preserved in post-install config.toml', () => {
|
||||
const preInstall = [
|
||||
'[hooks.state]',
|
||||
'',
|
||||
"[hooks.state.'/home/user/.codex/hooks.json:pre_tool_use:0:0']",
|
||||
'enabled = true',
|
||||
'trusted_hash = "sha256:abc123def456"',
|
||||
'',
|
||||
].join('\n');
|
||||
writeCodexConfig(preInstall);
|
||||
|
||||
runCodexInstall();
|
||||
|
||||
const after = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
|
||||
// Verify structurally: the trust hash key must survive the install.
|
||||
// Do NOT grep for the literal string — parse the TOML structure.
|
||||
const { parseTomlToObject } = require('../bin/install.js');
|
||||
const parsed = parseTomlToObject(after);
|
||||
assert.ok(
|
||||
parsed.hooks && typeof parsed.hooks.state === 'object' && parsed.hooks.state !== null,
|
||||
'post-install config.toml must have hooks.state as an object'
|
||||
);
|
||||
// Verify the actual trust entry survives — not just that hooks.state is an object.
|
||||
const trustKey = "/home/user/.codex/hooks.json:pre_tool_use:0:0";
|
||||
assert.ok(
|
||||
parsed.hooks.state[trustKey] != null,
|
||||
`post-install must preserve the original trust entry for key: ${trustKey}`
|
||||
);
|
||||
assert.strictEqual(
|
||||
parsed.hooks.state[trustKey].enabled,
|
||||
true,
|
||||
'preserved trust entry must have enabled = true'
|
||||
);
|
||||
assert.strictEqual(
|
||||
parsed.hooks.state[trustKey].trusted_hash,
|
||||
'sha256:abc123def456',
|
||||
'preserved trust entry must have the original trusted_hash'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,250 +0,0 @@
|
||||
/**
|
||||
* Regression test for #3288: model-catalog.cjs uses brittle relative path
|
||||
* that breaks after install.
|
||||
*
|
||||
* Repro:
|
||||
* After `node bin/install.js --global --claude`, the installed
|
||||
* `~/.claude/gsd-core/bin/lib/model-catalog.cjs` tries:
|
||||
* require(path.join(__dirname, '..', '..', '..', 'sdk', 'shared', 'model-catalog.json'))
|
||||
* which resolves to `~/.claude/sdk/shared/model-catalog.json`.
|
||||
* The installer copies `gsd-core/` but never copies `sdk/shared/`,
|
||||
* so the require throws MODULE_NOT_FOUND.
|
||||
*
|
||||
* Fix contract:
|
||||
* 1. model-catalog.cjs must use a resolve-chain that checks a co-located
|
||||
* path first (bin/shared/model-catalog.json) before the legacy
|
||||
* source-repo path.
|
||||
* 2. bin/install.js must copy shared model-catalog.json into
|
||||
* gsd-core/bin/shared/model-catalog.json (co-located inside the
|
||||
* gsd-core/ payload).
|
||||
*
|
||||
* Both halves must be true for the install layout to work.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
const MODEL_CATALOG_CJS = path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'model-catalog.cjs');
|
||||
const MODEL_CATALOG_JSON = path.join(REPO_ROOT, 'gsd-core', 'bin', 'shared', 'model-catalog.json');
|
||||
|
||||
const { install } = require('../bin/install.js');
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
const makeTmpDir = createTempDir;
|
||||
|
||||
const rmTmpDir = cleanup;
|
||||
|
||||
/**
|
||||
* Silence console output during install to avoid noise in test output.
|
||||
*/
|
||||
function silenceConsole(fn) {
|
||||
const orig = {
|
||||
log: console.log,
|
||||
warn: console.warn,
|
||||
error: console.error,
|
||||
};
|
||||
console.log = () => {};
|
||||
console.warn = () => {};
|
||||
console.error = () => {};
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
console.log = orig.log;
|
||||
console.warn = orig.warn;
|
||||
console.error = orig.error;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── test 1: fake-install layout reproduces MODULE_NOT_FOUND ────────────────
|
||||
//
|
||||
// Build a fake post-install layout that mirrors what the OLD install did:
|
||||
// <tmp>/.claude/gsd-core/bin/lib/model-catalog.cjs (copy of real file)
|
||||
// <tmp>/.claude/sdk/shared/model-catalog.json ABSENT
|
||||
//
|
||||
// Then attempt to require model-catalog.cjs from that layout.
|
||||
// Under the old path scheme (3 levels up → sdk/shared/) this should throw.
|
||||
// After the fix, if we DON'T also copy the json, it should still throw — this
|
||||
// confirms the co-located path IS required.
|
||||
|
||||
describe('bug #3288: model-catalog.cjs install-layout resolution', () => {
|
||||
let tmpRoot;
|
||||
let savedHome;
|
||||
let savedUserProfile;
|
||||
let savedExplicitConfigDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = makeTmpDir('gsd-3288-');
|
||||
savedHome = process.env.HOME;
|
||||
// On Windows, os.homedir() reads USERPROFILE (and HOMEDRIVE+HOMEPATH), NOT
|
||||
// HOME. install() resolves the install destination via os.homedir(), so the
|
||||
// tests must also redirect USERPROFILE → tmpRoot on win32 to keep the
|
||||
// installer writing inside the fixture.
|
||||
savedUserProfile = process.env.USERPROFILE;
|
||||
// Stash and clear explicitConfigDir via env so install() picks up our tmp dir.
|
||||
// Must delete (not just save) so any CI-set value doesn't leak into install()
|
||||
// and target a different directory than tmpRoot (CR finding, PR #3293).
|
||||
savedExplicitConfigDir = process.env.GSD_EXPLICIT_CONFIG_DIR;
|
||||
delete process.env.GSD_EXPLICIT_CONFIG_DIR;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.HOME = savedHome;
|
||||
if (savedUserProfile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = savedUserProfile;
|
||||
if (savedExplicitConfigDir === undefined) {
|
||||
delete process.env.GSD_EXPLICIT_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir;
|
||||
}
|
||||
rmTmpDir(tmpRoot);
|
||||
});
|
||||
|
||||
// ── test A ──────────────────────────────────────────────────────────────────
|
||||
test('OLD layout (3-level __dirname, no co-located json) fails to require', () => {
|
||||
// Build the old install layout manually:
|
||||
// <tmpRoot>/.claude/gsd-core/bin/lib/model-catalog.cjs (copy of the real CJS)
|
||||
// sdk/shared/model-catalog.json ABSENT
|
||||
const gsdLibDir = path.join(tmpRoot, '.claude', 'gsd-core', 'bin', 'lib');
|
||||
fs.mkdirSync(gsdLibDir, { recursive: true });
|
||||
|
||||
// Write a minimal model-catalog.cjs that uses ONLY the 3-level path (the old/broken path).
|
||||
const oldCjsContent = `'use strict';
|
||||
const path = require('node:path');
|
||||
// This is the BRITTLE path: 3 levels up from bin/lib → sdk/shared/
|
||||
const catalog = require(path.join(__dirname, '..', '..', '..', 'sdk', 'shared', 'model-catalog.json'));
|
||||
module.exports = { catalog };
|
||||
`;
|
||||
const catalogCjsPath = path.join(gsdLibDir, 'model-catalog.cjs');
|
||||
fs.writeFileSync(catalogCjsPath, oldCjsContent);
|
||||
|
||||
// Deliberately do NOT create sdk/shared/model-catalog.json (simulates missing file post-install).
|
||||
|
||||
// Require must fail with MODULE_NOT_FOUND.
|
||||
assert.throws(
|
||||
() => {
|
||||
// Delete from require cache to force a fresh load.
|
||||
delete require.cache[catalogCjsPath];
|
||||
require(catalogCjsPath);
|
||||
},
|
||||
(err) => {
|
||||
assert.ok(
|
||||
err.code === 'MODULE_NOT_FOUND' || err.message.includes('model-catalog.json'),
|
||||
`Expected MODULE_NOT_FOUND or model-catalog.json error, got: ${err.message}`,
|
||||
);
|
||||
return true;
|
||||
},
|
||||
'OLD 3-level path must fail when sdk/shared/model-catalog.json is not present (install layout)',
|
||||
);
|
||||
});
|
||||
|
||||
// ── test B ──────────────────────────────────────────────────────────────────
|
||||
test('NEW layout (co-located bin/shared/model-catalog.json) resolves correctly', () => {
|
||||
// Build the new install layout:
|
||||
// <tmpRoot>/.claude/gsd-core/bin/lib/model-catalog.cjs (copy of real CJS)
|
||||
// <tmpRoot>/.claude/gsd-core/bin/shared/model-catalog.json (co-located copy)
|
||||
const gsdBinDir = path.join(tmpRoot, '.claude', 'gsd-core', 'bin');
|
||||
const gsdLibDir = path.join(gsdBinDir, 'lib');
|
||||
const gsdSharedDir = path.join(gsdBinDir, 'shared');
|
||||
fs.mkdirSync(gsdLibDir, { recursive: true });
|
||||
fs.mkdirSync(gsdSharedDir, { recursive: true });
|
||||
|
||||
// Copy the real model-catalog.cjs into the fake install.
|
||||
const catalogCjsPath = path.join(gsdLibDir, 'model-catalog.cjs');
|
||||
fs.copyFileSync(MODEL_CATALOG_CJS, catalogCjsPath);
|
||||
|
||||
// Copy the real model-catalog.json to the co-located path.
|
||||
fs.copyFileSync(MODEL_CATALOG_JSON, path.join(gsdSharedDir, 'model-catalog.json'));
|
||||
|
||||
// Require must succeed and expose catalog with expected shape.
|
||||
delete require.cache[catalogCjsPath];
|
||||
let mod;
|
||||
assert.doesNotThrow(() => {
|
||||
mod = require(catalogCjsPath);
|
||||
}, 'NEW co-located layout must not throw MODULE_NOT_FOUND');
|
||||
|
||||
assert.ok(mod.catalog, 'module must export catalog');
|
||||
assert.ok(Array.isArray(mod.VALID_PROFILES), 'module must export VALID_PROFILES');
|
||||
assert.ok(mod.VALID_PROFILES.length > 0, 'VALID_PROFILES must not be empty');
|
||||
});
|
||||
|
||||
// ── test C ──────────────────────────────────────────────────────────────────
|
||||
test('post-install: install() copies model-catalog.json to co-located path', () => {
|
||||
// Run the real installer against a tmp target dir, then assert the co-located
|
||||
// json is present and parseable.
|
||||
const claudeDir = path.join(tmpRoot, '.claude');
|
||||
fs.mkdirSync(claudeDir, { recursive: true });
|
||||
process.env.HOME = tmpRoot;
|
||||
process.env.USERPROFILE = tmpRoot;
|
||||
|
||||
// Capture process.exit to prevent the test from being killed.
|
||||
const origExit = process.exit;
|
||||
let exitCalled = false;
|
||||
process.exit = (code) => {
|
||||
exitCalled = true;
|
||||
throw new Error(`process.exit(${code}) during install — should not happen`);
|
||||
};
|
||||
|
||||
try {
|
||||
silenceConsole(() => {
|
||||
install(true /* isGlobal */, 'claude');
|
||||
});
|
||||
} catch (e) {
|
||||
if (exitCalled) {
|
||||
assert.fail(`install() called process.exit — unexpected: ${e.message}`);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
process.exit = origExit;
|
||||
}
|
||||
|
||||
// The co-located json must be present after install.
|
||||
const colocatedJson = path.join(
|
||||
claudeDir,
|
||||
'gsd-core',
|
||||
'bin',
|
||||
'shared',
|
||||
'model-catalog.json',
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(colocatedJson),
|
||||
`model-catalog.json must be present at co-located path post-install: ${colocatedJson}`,
|
||||
);
|
||||
|
||||
// The json must be valid and have expected shape.
|
||||
let parsed;
|
||||
assert.doesNotThrow(() => {
|
||||
parsed = JSON.parse(fs.readFileSync(colocatedJson, 'utf8'));
|
||||
}, 'co-located model-catalog.json must be valid JSON');
|
||||
|
||||
assert.ok(Array.isArray(parsed.profiles), 'catalog.profiles must be an array');
|
||||
assert.ok(parsed.profiles.length > 0, 'catalog.profiles must not be empty');
|
||||
|
||||
// And the installed model-catalog.cjs must be requireable from its install location.
|
||||
const installedCjs = path.join(
|
||||
claudeDir,
|
||||
'gsd-core',
|
||||
'bin',
|
||||
'lib',
|
||||
'model-catalog.cjs',
|
||||
);
|
||||
assert.ok(fs.existsSync(installedCjs), `model-catalog.cjs must be installed at: ${installedCjs}`);
|
||||
|
||||
delete require.cache[installedCjs];
|
||||
let installedMod;
|
||||
assert.doesNotThrow(() => {
|
||||
installedMod = require(installedCjs);
|
||||
}, 'installed model-catalog.cjs must not throw MODULE_NOT_FOUND after install');
|
||||
|
||||
assert.ok(installedMod.catalog, 'installed module must export catalog');
|
||||
assert.ok(installedMod.VALID_PROFILES.length > 0, 'installed module must have valid profiles');
|
||||
});
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
/**
|
||||
* Regression: issue #3346 — Codex install fails on Windows when the legacy
|
||||
* Codex `[hooks]` config uses a `<file>:<event>:<line>:<col>` location tuple
|
||||
* as the table key (with the actual event name carried in an `event = "..."`
|
||||
* body field). `migrateCodexHooksMapFormat` re-emitted the location tuple
|
||||
* verbatim as the leaf TOML key, producing a header like
|
||||
*
|
||||
* [[hooks."C:\Users\helen\.codex\config.toml:session_start:0:0"]]
|
||||
*
|
||||
* which Codex 0.124.0+ refuses to load (the leaf key segment is supposed to
|
||||
* be the event name, not a diagnostic location identifier).
|
||||
*
|
||||
* Expected behaviour: when the legacy `[hooks.<X>]` body declares an
|
||||
* `event = "..."` field, the migrator must use that event name as the leaf
|
||||
* TOML key for the emitted `[[hooks.<EVENT>]]` two-level nested AoT block.
|
||||
*
|
||||
* Test discipline: parse the migrated TOML with the project's own
|
||||
* `parseTomlToObject` and assert on the resulting object shape — never
|
||||
* grep the raw string.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
migrateCodexHooksMapFormat,
|
||||
parseTomlToObject,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
describe('#3346 — Codex AoT hooks migration emits event-name leaf key, not location tuple', () => {
|
||||
test('legacy [hooks."<location-tuple>"] with event="..." body migrates to [[hooks.<event>]]', () => {
|
||||
// Pre-install fixture: a legacy `[hooks.<quoted-key>]` block whose key is
|
||||
// a `<config-path>:<event>:<line>:<col>` location identifier. The actual
|
||||
// event name lives in the body as `event = "session_start"`.
|
||||
const legacy = [
|
||||
'[hooks."C:\\\\Users\\\\helen\\\\.codex\\\\config.toml:session_start:0:0"]',
|
||||
'event = "session_start"',
|
||||
'command = "echo hi"',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const migrated = migrateCodexHooksMapFormat(legacy);
|
||||
const parsed = parseTomlToObject(migrated);
|
||||
|
||||
// The migrated hooks object must be keyed by the event name, not by the
|
||||
// location tuple. This is the core assertion of #3346.
|
||||
assert.ok(parsed.hooks, 'migrated TOML must define a hooks table');
|
||||
assert.deepEqual(
|
||||
Object.keys(parsed.hooks),
|
||||
['session_start'],
|
||||
`migrated hooks must be keyed by event name only; got: ${JSON.stringify(Object.keys(parsed.hooks))}`
|
||||
);
|
||||
|
||||
// The handler body must survive the migration and live under the two-level
|
||||
// nested AoT shape (hooks.<event>[0].hooks[0].command).
|
||||
const eventEntries = parsed.hooks.session_start;
|
||||
assert.ok(Array.isArray(eventEntries) && eventEntries.length >= 1,
|
||||
'hooks.session_start must be an array of tables');
|
||||
const handlers = eventEntries[0].hooks;
|
||||
assert.ok(Array.isArray(handlers) && handlers.length >= 1,
|
||||
'hooks.session_start[0].hooks must be an array of handler tables');
|
||||
assert.equal(handlers[0].command, 'echo hi',
|
||||
'handler command must be preserved through migration');
|
||||
assert.equal(handlers[0].type, 'command',
|
||||
'handler type must default to "command" when no explicit type given');
|
||||
assert.equal(handlers[0].event, undefined,
|
||||
'handler body must not retain legacy `event` field after migration');
|
||||
});
|
||||
|
||||
test('legacy [hooks."<location>"] with explicit type and event survives migration cleanly', () => {
|
||||
// Same as above but with an explicit `type` field — the migrator must not
|
||||
// duplicate it when re-emitting the handler.
|
||||
const legacy = [
|
||||
'[hooks."/home/user/.codex/config.toml:tool_call_pre:5:0"]',
|
||||
'event = "tool_call_pre"',
|
||||
'type = "command"',
|
||||
'command = "node /path/to/hook.js"',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const migrated = migrateCodexHooksMapFormat(legacy);
|
||||
const parsed = parseTomlToObject(migrated);
|
||||
|
||||
assert.deepEqual(
|
||||
Object.keys(parsed.hooks),
|
||||
['tool_call_pre'],
|
||||
'leaf key must be the event name from the `event = "..."` body field'
|
||||
);
|
||||
const handler = parsed.hooks.tool_call_pre[0].hooks[0];
|
||||
assert.equal(handler.command, 'node /path/to/hook.js');
|
||||
assert.equal(handler.type, 'command');
|
||||
assert.equal(handler.event, undefined,
|
||||
'handler body must not retain legacy `event` field after migration');
|
||||
});
|
||||
|
||||
test('legacy [hooks.<bare-event>] without location-tuple key continues to work unchanged', () => {
|
||||
// Regression guard: the fix must not break the canonical legacy-map case
|
||||
// ([hooks.<event-name>] with handler-fields-only body, no `event` key).
|
||||
const legacy = [
|
||||
'[hooks.session_start]',
|
||||
'command = "echo hi"',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const migrated = migrateCodexHooksMapFormat(legacy);
|
||||
const parsed = parseTomlToObject(migrated);
|
||||
|
||||
assert.deepEqual(
|
||||
Object.keys(parsed.hooks),
|
||||
['session_start'],
|
||||
'bare-event legacy shape must continue to migrate to event-named leaf key'
|
||||
);
|
||||
assert.equal(parsed.hooks.session_start[0].hooks[0].command, 'echo hi');
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* Regression test for bug #3360.
|
||||
*
|
||||
* Codex does not have a direct equivalent of Claude Code's
|
||||
* `Agent(... isolation="worktree")`. The execute-phase workflow must fail
|
||||
* closed for Codex + workflow.use_worktrees=true instead of spawning
|
||||
* workspace-write executors in the main checkout.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const EXECUTE_PHASE = path.join(ROOT, 'gsd-core', 'workflows', 'execute-phase.md');
|
||||
const { getCodexSkillAdapterHeader } = require('../bin/install.js');
|
||||
|
||||
function parseWorkflowSteps(content) {
|
||||
return [...content.matchAll(/<step name="([^"]+)"[^>]*>([\s\S]*?)<\/step>/g)]
|
||||
.map((match) => {
|
||||
const body = match[2];
|
||||
return {
|
||||
name: match[1],
|
||||
// After #3797 architectural fix, callsites use gsd_run
|
||||
readsRuntimeConfig: body.includes('RUNTIME=$(gsd_run query config-get runtime --default claude'),
|
||||
// #1521: guard generalized from Codex-specific to all non-Claude runtimes
|
||||
codexWorktreeGuard: body.includes('git worktree isolation') && body.includes('unsupported on runtime'),
|
||||
worktreeDispatchGuidance: body.includes('isolation="worktree"'),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function executePhaseWorktreeContract(content) {
|
||||
const steps = parseWorkflowSteps(content);
|
||||
const initializeIndex = steps.findIndex((step) => step.name === 'initialize');
|
||||
const firstWorktreeDispatchIndex = steps.findIndex((step) => step.worktreeDispatchGuidance);
|
||||
assert.notEqual(initializeIndex, -1, 'workflow must have an initialize step');
|
||||
assert.notEqual(firstWorktreeDispatchIndex, -1, 'workflow must still document worktree dispatch guidance');
|
||||
|
||||
const initialize = steps[initializeIndex];
|
||||
return {
|
||||
initializeReadsRuntimeConfig: initialize.readsRuntimeConfig,
|
||||
initializeHasCodexWorktreeGuard: initialize.codexWorktreeGuard,
|
||||
guardStepPrecedesWorktreeDispatch: initializeIndex <= firstWorktreeDispatchIndex,
|
||||
};
|
||||
}
|
||||
|
||||
describe('#3360 — Codex execute-phase fails closed for unsupported worktree isolation', () => {
|
||||
test('execute-phase reads runtime before worktree dispatch and blocks Codex worktree mode', () => {
|
||||
const workflow = fs.readFileSync(EXECUTE_PHASE, 'utf8');
|
||||
const contract = executePhaseWorktreeContract(workflow);
|
||||
|
||||
assert.deepEqual(contract, {
|
||||
initializeReadsRuntimeConfig: true,
|
||||
initializeHasCodexWorktreeGuard: true,
|
||||
guardStepPrecedesWorktreeDispatch: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('Codex adapter documents that worktree isolation has no direct spawn_agent mapping', () => {
|
||||
const header = getCodexSkillAdapterHeader('gsd-execute-phase');
|
||||
assert.match(header, /isolation="worktree"/);
|
||||
assert.match(header, /no direct Codex mapping/i);
|
||||
});
|
||||
});
|
||||
@@ -1,377 +0,0 @@
|
||||
/**
|
||||
* Regression tests for #338: Claude --local installs must write hook wiring to
|
||||
* `.claude/settings.local.json` (Claude Code's per-user gitignored slot) instead
|
||||
* of the repo-shared `.claude/settings.json`.
|
||||
*
|
||||
* Three cases:
|
||||
* 1. Fresh local install: settings.local.json is created with hook block;
|
||||
* settings.json is not touched.
|
||||
* 2. Global install (regression guard): continues to write to settings.json.
|
||||
* 3. Migration: if a prior local install wrote GSD entries to settings.json,
|
||||
* re-running local install moves them to settings.local.json and removes
|
||||
* them from settings.json in the same run.
|
||||
*
|
||||
* Note: `install()` only copies files. `finishInstall()` writes settings.
|
||||
* The production code calls both from `installAllRuntimes()`. Tests mirror
|
||||
* that two-phase pattern.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test, before, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
||||
const { install, finishInstall } = require(INSTALL_SRC);
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
// ─── Ensure hooks/dist/ is populated before install tests ────────────────────
|
||||
before(() => {
|
||||
execFileSync(process.execPath, [BUILD_SCRIPT], {
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Helper: run both install phases ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Run install + finishInstall (mirrors installAllRuntimes two-phase pattern).
|
||||
* @param {boolean} isGlobal
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.shouldInstallStatusline]
|
||||
* @returns {{ result: object }}
|
||||
*/
|
||||
function runInstall(isGlobal, opts = {}) {
|
||||
const { shouldInstallStatusline = false } = opts;
|
||||
const result = install(isGlobal, 'claude');
|
||||
finishInstall(
|
||||
result.settingsPath,
|
||||
result.settings,
|
||||
result.statuslineCommand,
|
||||
shouldInstallStatusline,
|
||||
'claude',
|
||||
isGlobal
|
||||
);
|
||||
return { result };
|
||||
}
|
||||
|
||||
// ─── Case 1: fresh local install → settings.local.json, not settings.json ───
|
||||
|
||||
describe('#338 case 1: fresh local Claude install writes to settings.local.json', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-338-local-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('settings.local.json is created with hook block', (t) => {
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
process.chdir(tmpDir);
|
||||
|
||||
runInstall(false);
|
||||
|
||||
const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json');
|
||||
assert.ok(
|
||||
fs.existsSync(localSettingsPath),
|
||||
'.claude/settings.local.json must exist after local Claude install (#338)'
|
||||
);
|
||||
|
||||
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
|
||||
assert.ok(
|
||||
settings && typeof settings === 'object',
|
||||
'settings.local.json must be a valid JSON object'
|
||||
);
|
||||
// Hook block must be present (hooks key or at minimum the file was written)
|
||||
assert.ok(
|
||||
settings.hooks !== undefined || Object.keys(settings).length >= 0,
|
||||
'settings.local.json must contain the hook block'
|
||||
);
|
||||
});
|
||||
|
||||
test('settings.json is NOT created by a fresh local install', (t) => {
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
process.chdir(tmpDir);
|
||||
|
||||
runInstall(false);
|
||||
|
||||
const sharedSettingsPath = path.join(tmpDir, '.claude', 'settings.json');
|
||||
assert.strictEqual(
|
||||
fs.existsSync(sharedSettingsPath),
|
||||
false,
|
||||
'.claude/settings.json must NOT be created by a fresh local Claude install (#338) — ' +
|
||||
'engineer-specific absolute paths must not leak into the repo-shared file'
|
||||
);
|
||||
});
|
||||
|
||||
test('install() returns settingsPath pointing to settings.local.json', (t) => {
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
process.chdir(tmpDir);
|
||||
|
||||
const result = install(false, 'claude');
|
||||
assert.ok(
|
||||
result.settingsPath.endsWith('settings.local.json'),
|
||||
`install() must return settingsPath ending in settings.local.json for local Claude installs; got: ${result.settingsPath}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Case 2: global Claude install (regression guard) ────────────────────────
|
||||
|
||||
describe('#338 case 2: global Claude install continues to write to settings.json', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-338-global-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('global install writes hook block to settings.json', (t) => {
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
|
||||
// Point CLAUDE_CONFIG_DIR at a subdir of tmpDir to avoid polluting ~/.claude
|
||||
const configDir = path.join(tmpDir, '.claude');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
const origEnv = process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir;
|
||||
t.after(() => {
|
||||
if (origEnv === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = origEnv;
|
||||
}
|
||||
});
|
||||
|
||||
runInstall(true);
|
||||
|
||||
const settingsPath = path.join(configDir, 'settings.json');
|
||||
assert.ok(
|
||||
fs.existsSync(settingsPath),
|
||||
'~/.claude/settings.json must exist after global Claude install (regression guard for #338)'
|
||||
);
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
||||
assert.ok(
|
||||
settings && typeof settings === 'object',
|
||||
'settings.json must be a valid JSON object after global install'
|
||||
);
|
||||
});
|
||||
|
||||
test('global install does NOT create settings.local.json', (t) => {
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
|
||||
const configDir = path.join(tmpDir, '.claude');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
const origEnv = process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir;
|
||||
t.after(() => {
|
||||
if (origEnv === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = origEnv;
|
||||
}
|
||||
});
|
||||
|
||||
runInstall(true);
|
||||
|
||||
const localSettingsPath = path.join(configDir, 'settings.local.json');
|
||||
assert.strictEqual(
|
||||
fs.existsSync(localSettingsPath),
|
||||
false,
|
||||
'~/.claude/settings.local.json must NOT be created by a global Claude install'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Case 3: migration — prior local install wrote GSD entries to settings.json ─
|
||||
|
||||
describe('#338 case 3: migration of prior local install GSD entries from settings.json to settings.local.json', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-338-migrate-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('GSD hook entries are moved from settings.json to settings.local.json', (t) => {
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
process.chdir(tmpDir);
|
||||
|
||||
// Pre-populate .claude/settings.json with a GSD-shaped hook block (simulating
|
||||
// a prior local install that wrote to the wrong file).
|
||||
const claudeDir = path.join(tmpDir, '.claude');
|
||||
fs.mkdirSync(claudeDir, { recursive: true });
|
||||
const sharedSettingsPath = path.join(claudeDir, 'settings.json');
|
||||
const priorSettings = {
|
||||
hooks: {
|
||||
SessionStart: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: `${process.execPath} ${path.join(claudeDir, 'hooks', 'gsd-check-update.js')}`,
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'Bash|Edit|Write|MultiEdit|Agent|Task',
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: `${process.execPath} ${path.join(claudeDir, 'hooks', 'gsd-context-monitor.js')}`,
|
||||
timeout: 10,
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
fs.writeFileSync(sharedSettingsPath, JSON.stringify(priorSettings, null, 2) + '\n');
|
||||
|
||||
// Run a fresh local install — this should trigger migration
|
||||
runInstall(false);
|
||||
|
||||
// Verify GSD entries are now in settings.local.json
|
||||
const localSettingsPath = path.join(claudeDir, 'settings.local.json');
|
||||
assert.ok(
|
||||
fs.existsSync(localSettingsPath),
|
||||
'.claude/settings.local.json must exist after migration run'
|
||||
);
|
||||
const localSettings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
|
||||
const sessionStartHooks = (localSettings.hooks && localSettings.hooks.SessionStart) || [];
|
||||
const hasGsdUpdateHook = sessionStartHooks.some(
|
||||
entry => entry && entry.hooks && Array.isArray(entry.hooks) &&
|
||||
entry.hooks.some(h => h && h.command && h.command.includes('gsd-check-update'))
|
||||
);
|
||||
assert.ok(
|
||||
hasGsdUpdateHook,
|
||||
'settings.local.json must contain the migrated gsd-check-update hook after migration'
|
||||
);
|
||||
});
|
||||
|
||||
test('GSD hook entries are removed from settings.json after migration', (t) => {
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
process.chdir(tmpDir);
|
||||
|
||||
const claudeDir = path.join(tmpDir, '.claude');
|
||||
fs.mkdirSync(claudeDir, { recursive: true });
|
||||
const sharedSettingsPath = path.join(claudeDir, 'settings.json');
|
||||
const priorSettings = {
|
||||
// Include a non-GSD key to verify user content is preserved
|
||||
myCustomKey: 'keep-me',
|
||||
hooks: {
|
||||
SessionStart: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: `${process.execPath} ${path.join(claudeDir, 'hooks', 'gsd-check-update.js')}`,
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
fs.writeFileSync(sharedSettingsPath, JSON.stringify(priorSettings, null, 2) + '\n');
|
||||
|
||||
runInstall(false);
|
||||
|
||||
// settings.json must exist (we don't delete it — user may have other content)
|
||||
assert.ok(
|
||||
fs.existsSync(sharedSettingsPath),
|
||||
'.claude/settings.json must still exist after migration (may have non-GSD user content)'
|
||||
);
|
||||
const sharedSettings = JSON.parse(fs.readFileSync(sharedSettingsPath, 'utf-8'));
|
||||
|
||||
// GSD hooks must be gone from settings.json
|
||||
const sessionStartHooks = (sharedSettings.hooks && sharedSettings.hooks.SessionStart) || [];
|
||||
const hasGsdHook = sessionStartHooks.some(
|
||||
entry => entry && entry.hooks && Array.isArray(entry.hooks) &&
|
||||
entry.hooks.some(h => h && h.command && h.command.includes('gsd-check-update'))
|
||||
);
|
||||
assert.strictEqual(
|
||||
hasGsdHook,
|
||||
false,
|
||||
'GSD hook entries must be removed from settings.json after migration to settings.local.json'
|
||||
);
|
||||
|
||||
// Non-GSD user content must be preserved
|
||||
assert.strictEqual(
|
||||
sharedSettings.myCustomKey,
|
||||
'keep-me',
|
||||
'Non-GSD user content in settings.json must be preserved during migration'
|
||||
);
|
||||
});
|
||||
|
||||
test('settings.json with no GSD entries is left unchanged', (t) => {
|
||||
const origCwd = process.cwd();
|
||||
t.after(() => { process.chdir(origCwd); });
|
||||
process.chdir(tmpDir);
|
||||
|
||||
const claudeDir = path.join(tmpDir, '.claude');
|
||||
fs.mkdirSync(claudeDir, { recursive: true });
|
||||
const sharedSettingsPath = path.join(claudeDir, 'settings.json');
|
||||
const userOnlySettings = {
|
||||
userKey: 'user-value',
|
||||
hooks: {
|
||||
SessionStart: [
|
||||
{
|
||||
hooks: [
|
||||
{
|
||||
type: 'command',
|
||||
command: '/usr/local/bin/my-own-hook.sh',
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
const originalContent = JSON.stringify(userOnlySettings, null, 2) + '\n';
|
||||
fs.writeFileSync(sharedSettingsPath, originalContent);
|
||||
|
||||
runInstall(false);
|
||||
|
||||
// settings.json must be unchanged (no GSD entries to migrate)
|
||||
const afterContent = fs.readFileSync(sharedSettingsPath, 'utf-8');
|
||||
const afterSettings = JSON.parse(afterContent);
|
||||
assert.strictEqual(
|
||||
afterSettings.userKey,
|
||||
'user-value',
|
||||
'Non-GSD settings.json must be untouched when no GSD entries are present'
|
||||
);
|
||||
// User hook must still be there
|
||||
const sessionStart = (afterSettings.hooks && afterSettings.hooks.SessionStart) || [];
|
||||
const hasUserHook = sessionStart.some(
|
||||
entry => entry && entry.hooks && entry.hooks.some(h => h && h.command === '/usr/local/bin/my-own-hook.sh')
|
||||
);
|
||||
assert.ok(
|
||||
hasUserHook,
|
||||
'User hook in settings.json must be preserved when no migration occurs'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,345 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Bug #3407: Installer leaves stale content in gsd-pristine/
|
||||
*
|
||||
* Root cause: populatePristineDir() in saveLocalPatches() snapshots from
|
||||
* pristineCtx.packageSrc — the NEWLY-downloaded release tree — and writes
|
||||
* those bytes into gsd-pristine/. For files changed between the old and new
|
||||
* release, this writes the NEW bytes into the pristine baseline instead of
|
||||
* the OLD bytes. The three-way-diff verifier then classifies upstream-changed
|
||||
* lines as user-added → Step 5a gate fails with false FAIL_USER_LINES_MISSING.
|
||||
*
|
||||
* The #3657 fix (OK_PRISTINE_DRIFT_DETECTED) was a symptom workaround: the
|
||||
* verifier detects hash mismatch (backup-meta.json records old-release hash
|
||||
* but gsd-pristine/ has new-release bytes) and skips to over-broad mode
|
||||
* instead of false-failing. The root-cause stale write was never fixed.
|
||||
*
|
||||
* Fix: when a correctly-populated gsd-pristine/ already exists from the
|
||||
* previous install (i.e., the file's sha256 matches the originalHash recorded
|
||||
* in the manifest), preserve it — do NOT wipe and re-populate from the new
|
||||
* release source. This ensures gsd-pristine/ holds old-release bytes even
|
||||
* after an upgrade where the file content changed upstream.
|
||||
*
|
||||
* Regression contract (byte-comparison):
|
||||
* After saveLocalPatches() is called with a user-modified file whose
|
||||
* gsd-pristine/ entry was correctly set by the previous install, the
|
||||
* gsd-pristine/ file MUST still contain the old-release bytes, not the
|
||||
* new-release bytes supplied in pristineCtx.packageSrc.
|
||||
*
|
||||
* Closes: #3407
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const INSTALL = require(path.join(ROOT, 'bin', 'install.js'));
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const MANIFEST_NAME = 'gsd-file-manifest.json';
|
||||
const PATCHES_DIR_NAME = 'gsd-local-patches';
|
||||
|
||||
function sha256(content) {
|
||||
return crypto.createHash('sha256').update(content instanceof Buffer ? content : Buffer.from(content)).digest('hex');
|
||||
}
|
||||
|
||||
// ─── Bug #3407: gsd-pristine/ must preserve OLD-release bytes across upgrade ──
|
||||
|
||||
describe('Bug #3407: saveLocalPatches preserves old-release pristine across upgrade', () => {
|
||||
let tmpDir;
|
||||
let configDir;
|
||||
let fakeSrcDir;
|
||||
|
||||
beforeEach((t) => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3407-'));
|
||||
configDir = path.join(tmpDir, 'config');
|
||||
fakeSrcDir = path.join(tmpDir, 'new-release-src');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.mkdirSync(fakeSrcDir, { recursive: true });
|
||||
t.after(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Core regression test.
|
||||
*
|
||||
* Timeline:
|
||||
* Install v1: file content = OLD_RELEASE_CONTENT, gsd-pristine/ROOT_FILE
|
||||
* = OLD_RELEASE_CONTENT (correctly set by previous install),
|
||||
* manifest hash = sha256(OLD_RELEASE_CONTENT)
|
||||
* User edits: configDir/ROOT_FILE = USER_MODIFIED_CONTENT
|
||||
* Upgrade v2: pristineCtx.packageSrc has NEW_RELEASE_CONTENT for ROOT_FILE
|
||||
* saveLocalPatches is called before the wipe.
|
||||
*
|
||||
* Expected AFTER fix: gsd-pristine/ROOT_FILE still == OLD_RELEASE_CONTENT
|
||||
* Actual BEFORE fix: gsd-pristine/ROOT_FILE == NEW_RELEASE_CONTENT (stale)
|
||||
*/
|
||||
test('gsd-pristine/ retains old-release bytes when upgrading a user-modified file', () => {
|
||||
const OLD_RELEASE_CONTENT = '# Old Release Content\nThis is v1 pristine.\n';
|
||||
const NEW_RELEASE_CONTENT = '# New Release Content\nThis is v2 — upstream changed this line.\n';
|
||||
const USER_MODIFIED_CONTENT = '# Old Release Content\nThis is v1 pristine.\n## User addition\nUser customization here.\n';
|
||||
|
||||
const oldHash = sha256(OLD_RELEASE_CONTENT);
|
||||
|
||||
// Simulate a root-level installed file. Root-level files in the manifest
|
||||
// are denoted without a subdirectory (slash-free relPath).
|
||||
const relPath = 'test-root-file.md';
|
||||
|
||||
// Set up configDir: user-modified installed file + manifest recording old hash
|
||||
fs.writeFileSync(path.join(configDir, relPath), USER_MODIFIED_CONTENT);
|
||||
fs.writeFileSync(
|
||||
path.join(configDir, MANIFEST_NAME),
|
||||
JSON.stringify({ version: '1.0.0', files: { [relPath]: oldHash } }, null, 2)
|
||||
);
|
||||
|
||||
// Set up fakeSrcDir (new release): the file has NEW content
|
||||
fs.writeFileSync(path.join(fakeSrcDir, relPath), NEW_RELEASE_CONTENT);
|
||||
|
||||
// Set up gsd-pristine/ with OLD content (as correctly populated by previous install)
|
||||
const pristineDir = path.join(configDir, 'gsd-pristine');
|
||||
fs.mkdirSync(pristineDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(pristineDir, relPath), OLD_RELEASE_CONTENT);
|
||||
|
||||
// Call saveLocalPatches with the new release as packageSrc (the buggy scenario)
|
||||
INSTALL.saveLocalPatches(configDir, {
|
||||
packageSrc: fakeSrcDir,
|
||||
runtime: 'claude',
|
||||
pathPrefix: '$HOME/.claude/',
|
||||
isGlobal: true,
|
||||
});
|
||||
|
||||
// Assert: gsd-pristine/ must still contain OLD-release bytes
|
||||
const pristineFile = path.join(pristineDir, relPath);
|
||||
assert.ok(
|
||||
fs.existsSync(pristineFile),
|
||||
`gsd-pristine/${relPath} must exist after saveLocalPatches`
|
||||
);
|
||||
|
||||
const actualPristineContent = fs.readFileSync(pristineFile, 'utf8');
|
||||
assert.equal(
|
||||
sha256(actualPristineContent),
|
||||
oldHash,
|
||||
[
|
||||
`gsd-pristine/${relPath} must contain OLD-release bytes (sha256=${oldHash.slice(0, 12)}…)`,
|
||||
`but got sha256=${sha256(actualPristineContent).slice(0, 12)}…`,
|
||||
`(If equal to sha256(NEW_RELEASE_CONTENT)=${sha256(NEW_RELEASE_CONTENT).slice(0, 12)}… then #3407 is NOT fixed)`,
|
||||
].join(' ')
|
||||
);
|
||||
|
||||
// Secondary: confirm backup-meta records the old hash (not new)
|
||||
const backupMeta = JSON.parse(
|
||||
fs.readFileSync(path.join(configDir, PATCHES_DIR_NAME, 'backup-meta.json'), 'utf8')
|
||||
);
|
||||
assert.ok(
|
||||
Object.prototype.hasOwnProperty.call(backupMeta.pristine_hashes, relPath),
|
||||
'backup-meta.json must record pristine_hash for modified file'
|
||||
);
|
||||
assert.equal(
|
||||
backupMeta.pristine_hashes[relPath],
|
||||
oldHash,
|
||||
'backup-meta.json pristine_hash must equal old-release hash (not new-release hash)'
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Regression test for Codex finding: when gsd-pristine/ entry is absent
|
||||
* (e.g., post-buggy-run deletion or first upgrade without prior pristine)
|
||||
* but the file is UNCHANGED between old and new release, the hash-validated
|
||||
* regeneration path must restore the pristine entry using new-release source.
|
||||
*
|
||||
* When sha256(newReleaseBytesForFile) === originalHash, the file is identical
|
||||
* between releases — new-release generated bytes ARE the old-release pristine
|
||||
* and may be safely promoted.
|
||||
*
|
||||
* Previously (before the regeneration path was added): missing entries were
|
||||
* left absent unconditionally, causing permanent over-broad fallback even
|
||||
* when the file was unchanged upstream.
|
||||
*/
|
||||
test('gsd-pristine/ is regenerated for missing entries when file is unchanged between releases', () => {
|
||||
const SHARED_RELEASE_CONTENT = '# Shared Content\nThis file is identical in v1 and v2.\n';
|
||||
const USER_MODIFIED_CONTENT = '# Shared Content\nThis file is identical in v1 and v2.\n## User addition\nCustom.\n';
|
||||
|
||||
const oldHash = sha256(SHARED_RELEASE_CONTENT);
|
||||
const relPath = 'test-unchanged-file.md';
|
||||
|
||||
// configDir has user-modified file + manifest with old-release hash
|
||||
fs.writeFileSync(path.join(configDir, relPath), USER_MODIFIED_CONTENT);
|
||||
fs.writeFileSync(
|
||||
path.join(configDir, MANIFEST_NAME),
|
||||
JSON.stringify({ version: '1.0.0', files: { [relPath]: oldHash } }, null, 2)
|
||||
);
|
||||
|
||||
// fakeSrcDir (new release) has the SAME content — file was not changed upstream
|
||||
fs.writeFileSync(path.join(fakeSrcDir, relPath), SHARED_RELEASE_CONTENT);
|
||||
|
||||
// NOTE: gsd-pristine/ does NOT exist (simulating post-buggy-run or first-time scenario)
|
||||
|
||||
INSTALL.saveLocalPatches(configDir, {
|
||||
packageSrc: fakeSrcDir,
|
||||
runtime: 'claude',
|
||||
pathPrefix: '$HOME/.claude/',
|
||||
isGlobal: true,
|
||||
});
|
||||
|
||||
// The regeneration path should have detected that sha256(new-release candidate)
|
||||
// === originalHash, and promoted the candidate into gsd-pristine/.
|
||||
const pristineFile = path.join(configDir, 'gsd-pristine', relPath);
|
||||
assert.ok(
|
||||
fs.existsSync(pristineFile),
|
||||
[
|
||||
`gsd-pristine/${relPath} must exist after hash-validated regeneration.`,
|
||||
`When new-release bytes hash to originalHash, the file was unchanged between`,
|
||||
`releases and the candidate should be promoted to restore the pristine baseline.`,
|
||||
].join(' ')
|
||||
);
|
||||
|
||||
const actualContent = fs.readFileSync(pristineFile, 'utf8');
|
||||
assert.equal(
|
||||
sha256(actualContent),
|
||||
oldHash,
|
||||
[
|
||||
`gsd-pristine/${relPath} must contain bytes matching originalHash after regeneration`,
|
||||
`(sha256=${oldHash.slice(0, 12)}…)`,
|
||||
].join(' ')
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Stale-pristine recovery test (pre-fix bug artifact).
|
||||
*
|
||||
* Timeline:
|
||||
* Buggy run: gsd-pristine/<rel> was written with NEW_RELEASE_CONTENT
|
||||
* (the exact #3407 artifact — stale bytes from a buggy populatePristineDir).
|
||||
* Fix run: saveLocalPatches detects the hash mismatch
|
||||
* (sha256(NEW_RELEASE_CONTENT) !== originalHash recorded in manifest),
|
||||
* removes the stale entry, then attempts regeneration.
|
||||
*
|
||||
* When the file CHANGED between releases (NEW !== OLD):
|
||||
* - The stale entry is removed.
|
||||
* - Regeneration discards the new-release candidate (hash mismatch).
|
||||
* - gsd-pristine/<rel> must be ABSENT (over-broad fallback — correct).
|
||||
*
|
||||
* When the file is UNCHANGED between releases (NEW === OLD):
|
||||
* - The stale entry (which happens to have correct bytes despite the bug) is
|
||||
* detected as correct (hash matches originalHash) and PRESERVED.
|
||||
* - gsd-pristine/<rel> must remain present with the correct bytes.
|
||||
*
|
||||
* This test covers the "file changed across release boundary" case.
|
||||
* The "unchanged" case is already covered by the regeneration test above.
|
||||
*/
|
||||
test('stale gsd-pristine/ entry (new-release bytes) is removed when file changed between releases', () => {
|
||||
const OLD_RELEASE_CONTENT = '# Old Release\nv1 content here.\n';
|
||||
const NEW_RELEASE_CONTENT = '# New Release\nv2 content — upstream changed this.\n';
|
||||
const USER_MODIFIED_CONTENT = '# Old Release\nv1 content here.\n## User section\nCustom work.\n';
|
||||
|
||||
const oldHash = sha256(OLD_RELEASE_CONTENT);
|
||||
const relPath = 'test-stale-recovery.md';
|
||||
|
||||
// configDir: user-modified file + manifest recording OLD hash
|
||||
fs.writeFileSync(path.join(configDir, relPath), USER_MODIFIED_CONTENT);
|
||||
fs.writeFileSync(
|
||||
path.join(configDir, MANIFEST_NAME),
|
||||
JSON.stringify({ version: '1.0.0', files: { [relPath]: oldHash } }, null, 2)
|
||||
);
|
||||
|
||||
// fakeSrcDir (new release): contains the NEW content
|
||||
fs.writeFileSync(path.join(fakeSrcDir, relPath), NEW_RELEASE_CONTENT);
|
||||
|
||||
// Pre-populate gsd-pristine/ with NEW_RELEASE_CONTENT — the exact pre-fix bug artifact.
|
||||
// This simulates a prior buggy run that wrote new-release bytes into the pristine baseline.
|
||||
const STALE_BYTES = NEW_RELEASE_CONTENT; // named constant for clarity
|
||||
const pristineDir = path.join(configDir, 'gsd-pristine');
|
||||
fs.mkdirSync(pristineDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(pristineDir, relPath), STALE_BYTES);
|
||||
|
||||
// Verify the pre-condition: stale bytes do NOT match the original hash.
|
||||
// If this assert fails, the test fixture is wrong (not a fix regression).
|
||||
assert.notEqual(
|
||||
sha256(STALE_BYTES),
|
||||
oldHash,
|
||||
'test fixture check: stale bytes must differ from originalHash'
|
||||
);
|
||||
|
||||
INSTALL.saveLocalPatches(configDir, {
|
||||
packageSrc: fakeSrcDir,
|
||||
runtime: 'claude',
|
||||
pathPrefix: '$HOME/.claude/',
|
||||
isGlobal: true,
|
||||
});
|
||||
|
||||
// The fix must detect the hash mismatch (stale entry) and remove it.
|
||||
// The regeneration path discards the new-release candidate (its hash !== oldHash).
|
||||
// Result: gsd-pristine/<rel> must be ABSENT — over-broad fallback is the safe outcome.
|
||||
const pristineFile = path.join(pristineDir, relPath);
|
||||
assert.strictEqual(
|
||||
fs.existsSync(pristineFile),
|
||||
false,
|
||||
[
|
||||
`expected gsd-pristine/${relPath} to be absent after stale-pristine recovery.`,
|
||||
`The stale entry (new-release bytes, sha256=${sha256(STALE_BYTES).slice(0, 12)}…)`,
|
||||
`must be removed; regeneration must discard the candidate because`,
|
||||
`sha256(new-release)=${sha256(NEW_RELEASE_CONTENT).slice(0, 12)}… !== originalHash=${oldHash.slice(0, 12)}….`,
|
||||
`Presence of the file means the stale bytes were NOT cleaned up (pre-fix behavior).`,
|
||||
].join(' ')
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Second scenario: gsd-pristine/ does NOT pre-exist (first upgrade with no
|
||||
* prior pristine population). In this case there is no way to obtain the
|
||||
* old-release pristine bytes — populatePristineDir must NOT write the new-
|
||||
* release bytes either. The correct outcome is: gsd-pristine/ stays empty
|
||||
* for this file, and the verifier falls back to over-broad mode (safe).
|
||||
*/
|
||||
test('gsd-pristine/ stays empty when no prior pristine exists (first upgrade, no stale write)', () => {
|
||||
const OLD_RELEASE_CONTENT = '# Old Release Content\nThis is v1.\n';
|
||||
const NEW_RELEASE_CONTENT = '# New Release Content\nThis is v2 — changed.\n';
|
||||
const USER_MODIFIED_CONTENT = '# Old Release Content\nThis is v1.\n## User addition\nCustom.\n';
|
||||
|
||||
const oldHash = sha256(OLD_RELEASE_CONTENT);
|
||||
const relPath = 'test-first-upgrade.md';
|
||||
|
||||
// configDir has user-modified file + manifest
|
||||
fs.writeFileSync(path.join(configDir, relPath), USER_MODIFIED_CONTENT);
|
||||
fs.writeFileSync(
|
||||
path.join(configDir, MANIFEST_NAME),
|
||||
JSON.stringify({ version: '1.0.0', files: { [relPath]: oldHash } }, null, 2)
|
||||
);
|
||||
|
||||
// fakeSrcDir (new release) has new content
|
||||
fs.writeFileSync(path.join(fakeSrcDir, relPath), NEW_RELEASE_CONTENT);
|
||||
|
||||
// NOTE: gsd-pristine/ does NOT exist yet (first upgrade)
|
||||
|
||||
INSTALL.saveLocalPatches(configDir, {
|
||||
packageSrc: fakeSrcDir,
|
||||
runtime: 'claude',
|
||||
pathPrefix: '$HOME/.claude/',
|
||||
isGlobal: true,
|
||||
});
|
||||
|
||||
const pristineFile = path.join(configDir, 'gsd-pristine', relPath);
|
||||
assert.strictEqual(
|
||||
fs.existsSync(pristineFile),
|
||||
false,
|
||||
[
|
||||
`expected gsd-pristine/${relPath} to be absent when file changed across release boundary.`,
|
||||
`Writing new-release bytes as pristine for a file whose hash is unknown leads to`,
|
||||
`false FAIL_USER_LINES_MISSING in the reapply-patches verifier (#3407).`,
|
||||
`Over-broad fallback mode is the correct outcome here.`,
|
||||
].join(' ')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// The former "Antipattern hunt" describe block (structural typeof checks only) was
|
||||
// removed — it provided no real behavioral coverage and was a vacuous-truth pattern
|
||||
// per /test-rigor skill. Behavioral tests for populatePristineDir are covered above.
|
||||
@@ -1,473 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Bug #3426 — Codex on Windows: SessionStart/PostToolUse hooks fail with exit code 1
|
||||
*
|
||||
* After PRs #3396/#3397 fixed bare-bash and quote-escaping issues, a new failure
|
||||
* mode appeared on v1.42.3+:
|
||||
*
|
||||
* Failed with non-blocking status code:
|
||||
* C:/Program Files/Git/bin/bash.exe: C:/Program Files/Git/bin/bash.exe: cannot execute binary file
|
||||
*
|
||||
* Root cause: Codex on Windows runs hook commands from a PowerShell/cmd
|
||||
* execution environment (see install.js comment at buildHookCommand). The
|
||||
* command string written to hooks.json was:
|
||||
*
|
||||
* "C:/Program Files/nodejs/node.exe" "C:/path/.codex/hooks/gsd-check-update.js"
|
||||
*
|
||||
* When Codex's hook runner passes this to its subprocess spawner, the quoted
|
||||
* path resolves through Git Bash (MSYS), which then tries to POSIX-exec
|
||||
* node.exe — a Windows PE binary — via the MSYS exec layer. The MSYS exec
|
||||
* path calls execvp() on the PE binary directly, which fails with ENOEXEC,
|
||||
* reported as "cannot execute binary file". The "bash.exe: bash.exe:" prefix
|
||||
* appears because the error propagates through the bash.exe process that Codex
|
||||
* uses as its hook-dispatch shell.
|
||||
*
|
||||
* Fix: on Windows, write a .cmd shim (using the same buildWindowsShimTriple
|
||||
* IR pattern as gsd-sdk.cmd) and put the .cmd path as the hooks.json command.
|
||||
* cmd.exe executes .cmd files natively via CreateProcess — no POSIX exec layer,
|
||||
* no MSYS shebang walk.
|
||||
*
|
||||
* Test strategy:
|
||||
* - Assert on the typed IR returned by buildCodexHookWindowsShimIR — not on
|
||||
* rendered .cmd text (per CONTRIBUTING.md L558-L565 IR-first discipline).
|
||||
* - Counter-tests confirm darwin/linux paths are unchanged.
|
||||
*
|
||||
* NOTE: Windows wall-clock verification depends on Docker matrix Windows
|
||||
* runners. Local test exercises the generator IR shape only.
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const INSTALL = require('../bin/install.js');
|
||||
const PROJECTION = require('../gsd-core/bin/lib/shell-command-projection.cjs');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const {
|
||||
buildCodexHookWindowsShimIR,
|
||||
ensureCodexHooksJsonSessionStart,
|
||||
resolveNodeRunner,
|
||||
uninstall,
|
||||
} = INSTALL;
|
||||
|
||||
const { projectManagedHookCommand } = PROJECTION;
|
||||
|
||||
/**
|
||||
* Extract hook handler objects for `eventName` from a hooks.json object.
|
||||
* Handles both the legacy top-level shape { SessionStart: [...] } and the
|
||||
* canonical nested shape { hooks: { SessionStart: [...] } } (bug #1348).
|
||||
*/
|
||||
function hookHandlersForEvent(hooksJson, eventName) {
|
||||
if (!hooksJson || typeof hooksJson !== 'object') return [];
|
||||
const table =
|
||||
hooksJson.hooks && typeof hooksJson.hooks === 'object' && !Array.isArray(hooksJson.hooks)
|
||||
? hooksJson.hooks
|
||||
: hooksJson;
|
||||
if (!Array.isArray(table[eventName])) return [];
|
||||
return table[eventName].flatMap((e) => Array.isArray(e && e.hooks) ? e.hooks : []);
|
||||
}
|
||||
|
||||
// ─── Step 1: Export surface check ────────────────────────────────────────────
|
||||
|
||||
describe('#3426 — export surface: buildCodexHookWindowsShimIR must be exported', () => {
|
||||
test('buildCodexHookWindowsShimIR is a function', () => {
|
||||
assert.equal(typeof buildCodexHookWindowsShimIR, 'function',
|
||||
'buildCodexHookWindowsShimIR must be exported from bin/install.js');
|
||||
});
|
||||
|
||||
test('ensureCodexHooksJsonSessionStart is a function', () => {
|
||||
assert.equal(typeof ensureCodexHooksJsonSessionStart, 'function',
|
||||
'ensureCodexHooksJsonSessionStart must be exported from bin/install.js');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Step 2: Typed IR shape for Windows Codex hook shim ──────────────────────
|
||||
|
||||
describe('#3426 — buildCodexHookWindowsShimIR: typed IR (not rendered text)', () => {
|
||||
const FAKE_SCRIPT = 'C:/Users/me/.codex/hooks/gsd-check-update.js';
|
||||
const FAKE_RUNNER = '"C:/Program Files/nodejs/node.exe"';
|
||||
|
||||
test('returns typed IR with invocation, cmdPath, and render factory', () => {
|
||||
const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER);
|
||||
// IR shape assertion — per CONTRIBUTING.md L558 IR-first discipline
|
||||
assert.ok(ir && typeof ir === 'object', 'must return an object');
|
||||
assert.ok(typeof ir.invocation === 'object', 'must have invocation record');
|
||||
assert.ok(typeof ir.cmdPath === 'string', 'must have cmdPath string');
|
||||
assert.ok(typeof ir.hookCommand === 'string', 'must have hookCommand string (written to hooks.json)');
|
||||
assert.ok(typeof ir.render === 'object', 'must have render factory');
|
||||
assert.ok(typeof ir.render.cmd === 'function', 'must have render.cmd() factory');
|
||||
});
|
||||
|
||||
test('invocation.target equals the resolved script path', () => {
|
||||
const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER);
|
||||
// invocation.target is the JS file being wrapped — same IR contract as buildWindowsShimTriple
|
||||
assert.ok(
|
||||
ir.invocation.target.includes('gsd-check-update.js'),
|
||||
`invocation.target must reference the hook script, got: ${ir.invocation.target}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('invocation.interpreter is the node runner (not bash)', () => {
|
||||
const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER);
|
||||
// The shim must invoke node, never bash — bash is not a valid Codex hook runner on Windows
|
||||
const interp = ir.invocation.interpreter;
|
||||
assert.ok(
|
||||
typeof interp === 'string' && (interp.includes('node') || interp === 'node'),
|
||||
`invocation.interpreter must be a node path, not bash. Got: ${interp}`,
|
||||
);
|
||||
assert.ok(
|
||||
!interp.toLowerCase().includes('bash'),
|
||||
`invocation.interpreter must NOT be bash — bash is the source of the #3426 failure. Got: ${interp}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('cmdPath ends with .cmd extension', () => {
|
||||
const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER);
|
||||
assert.ok(
|
||||
ir.cmdPath.endsWith('.cmd'),
|
||||
`cmdPath must end with .cmd for cmd.exe native execution, got: ${ir.cmdPath}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('hookCommand is the .cmd path (not a "runner script.js" string)', () => {
|
||||
const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER);
|
||||
// The hook command written to hooks.json must be the .cmd path, not "node.exe script.js"
|
||||
// because cmd.exe executes .cmd natively without POSIX exec layer
|
||||
assert.ok(
|
||||
ir.hookCommand.includes('.cmd'),
|
||||
`hookCommand must reference the .cmd shim, got: ${ir.hookCommand}`,
|
||||
);
|
||||
// hookCommand must NOT contain bash — this was the failure mode
|
||||
assert.ok(
|
||||
!ir.hookCommand.toLowerCase().includes('bash'),
|
||||
`hookCommand must NOT reference bash, got: ${ir.hookCommand}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('returns null when absoluteRunnerToken is null (caller skips registration)', () => {
|
||||
const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, null);
|
||||
assert.equal(ir, null,
|
||||
'must return null when runner is unavailable so caller can warn-and-skip');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Step 2b: Typed IR — eol / quoting / passthroughArgs ─────────────────────
|
||||
// Per CONTRIBUTING.md L558-L565: assert on the typed IR, not on rendered text.
|
||||
// These assertions cover the three bug-critical render semantics that
|
||||
// text-matching tests would miss (silent EOL/quoting/passthrough regressions).
|
||||
|
||||
describe('#3426 — buildCodexHookWindowsShimIR: typed IR eol / quoting / passthroughArgs', () => {
|
||||
const FAKE_SCRIPT = 'C:/Users/me/.codex/hooks/gsd-check-update.js';
|
||||
const FAKE_RUNNER = '"C:/Program Files/nodejs/node.exe"';
|
||||
|
||||
test('eol.cmd is CRLF (\\r\\n) — canonical for cmd.exe .cmd files', () => {
|
||||
const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER);
|
||||
assert.ok(ir && typeof ir.eol === 'object', 'IR must expose an eol field');
|
||||
assert.strictEqual(
|
||||
ir.eol.cmd,
|
||||
'\r\n',
|
||||
'eol.cmd must be CRLF (\\r\\n) — LF-only .cmd files risk silent parse failures on some Windows versions',
|
||||
);
|
||||
});
|
||||
|
||||
test('invocation.target has no shell-metachar leakage (clean absolute path)', () => {
|
||||
const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER);
|
||||
const target = ir.invocation.target;
|
||||
assert.ok(typeof target === 'string' && target.length > 0, 'invocation.target must be a non-empty string');
|
||||
// The target stored in the IR is the raw unquoted path — quoting happens at
|
||||
// render time. A metachar in the raw value means the IR is already corrupted.
|
||||
assert.ok(
|
||||
!target.includes('"') && !target.includes("'") && !target.includes('`'),
|
||||
`invocation.target must be the raw path without shell quoting, got: ${target}`,
|
||||
);
|
||||
assert.ok(
|
||||
target.endsWith('.js'),
|
||||
`invocation.target must resolve to the .js script, got: ${target}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('passthroughArgs is true — shim forwards all args via %*', () => {
|
||||
const ir = buildCodexHookWindowsShimIR(FAKE_SCRIPT, FAKE_RUNNER);
|
||||
assert.strictEqual(
|
||||
ir.passthroughArgs,
|
||||
true,
|
||||
'passthroughArgs must be true: the .cmd shim must forward all arguments to the node script via %*',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Step 3: Counter-test — non-Windows platforms use node-runner command ────
|
||||
|
||||
describe('#3426 counter-test: darwin/linux Codex paths use node-runner command (not .cmd shim)', () => {
|
||||
test('projectManagedHookCommand on darwin emits node-runner command, not .cmd', () => {
|
||||
const runner = resolveNodeRunner() || '"/usr/local/bin/node"';
|
||||
const cmd = projectManagedHookCommand({
|
||||
absoluteRunner: runner,
|
||||
scriptPath: '/Users/me/.codex/hooks/gsd-check-update.js',
|
||||
runtime: 'codex',
|
||||
platform: 'darwin',
|
||||
});
|
||||
assert.ok(typeof cmd === 'string', 'must return a string on darwin');
|
||||
assert.ok(!cmd.endsWith('.cmd'), 'darwin command must NOT reference a .cmd shim');
|
||||
assert.ok(
|
||||
cmd.includes('gsd-check-update.js'),
|
||||
`darwin command must reference the .js hook directly, got: ${cmd}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('projectManagedHookCommand on linux emits node-runner command, not .cmd', () => {
|
||||
const runner = resolveNodeRunner() || '"/usr/local/bin/node"';
|
||||
const cmd = projectManagedHookCommand({
|
||||
absoluteRunner: runner,
|
||||
scriptPath: '/home/me/.codex/hooks/gsd-check-update.js',
|
||||
runtime: 'codex',
|
||||
platform: 'linux',
|
||||
});
|
||||
assert.ok(typeof cmd === 'string', 'must return a string on linux');
|
||||
assert.ok(!cmd.endsWith('.cmd'), 'linux command must NOT reference a .cmd shim');
|
||||
assert.ok(
|
||||
cmd.includes('gsd-check-update.js'),
|
||||
`linux command must reference the .js hook directly, got: ${cmd}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Step 4: Integration — ensureCodexHooksJsonSessionStart on win32 writes .cmd shim ──
|
||||
|
||||
describe('#3426 integration: ensureCodexHooksJsonSessionStart on win32 writes .cmd shim', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-3426-');
|
||||
fs.mkdirSync(path.join(tmpDir, 'hooks'), { recursive: true });
|
||||
// Stub the hook file that must exist for the hook to be registered
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'hooks', 'gsd-check-update.js'),
|
||||
'#!/usr/bin/env node\nconsole.log("ok");\n',
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('win32: hooks.json command references .cmd shim (not "node.exe script.js")', () => {
|
||||
const fakeRunner = '"C:/Program Files/nodejs/node.exe"';
|
||||
|
||||
const result = ensureCodexHooksJsonSessionStart(tmpDir, {
|
||||
absoluteRunner: fakeRunner,
|
||||
platform: 'win32',
|
||||
});
|
||||
|
||||
assert.ok(result.wrote || result.changed, 'must write hooks.json on win32');
|
||||
|
||||
const hooksJsonPath = path.join(tmpDir, 'hooks.json');
|
||||
assert.ok(fs.existsSync(hooksJsonPath), 'hooks.json must exist after install');
|
||||
|
||||
const hooksJson = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
|
||||
// #1348: hooks.json is now always written in nested { hooks: { ... } } shape
|
||||
const commands = hookHandlersForEvent(hooksJson, 'SessionStart')
|
||||
.map((h) => h && h.command)
|
||||
.filter((c) => typeof c === 'string');
|
||||
|
||||
assert.ok(commands.length > 0, 'must have at least one SessionStart hook command');
|
||||
|
||||
const cmd = commands.find((c) => c.includes('gsd-check-update'));
|
||||
assert.ok(cmd, 'must have a gsd-check-update hook command');
|
||||
|
||||
// KEY ASSERTION: on win32, the command must reference a .cmd file — not bash
|
||||
assert.ok(
|
||||
cmd.includes('.cmd'),
|
||||
`win32 hook command must reference a .cmd shim to avoid bash.exe exec failure (#3426). Got: ${cmd}`,
|
||||
);
|
||||
assert.ok(
|
||||
!cmd.toLowerCase().includes('bash'),
|
||||
`win32 hook command must NOT reference bash.exe — this was the #3426 failure. Got: ${cmd}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('win32: .cmd shim file is written to the hooks directory', () => {
|
||||
const fakeRunner = '"C:/Program Files/nodejs/node.exe"';
|
||||
|
||||
ensureCodexHooksJsonSessionStart(tmpDir, {
|
||||
absoluteRunner: fakeRunner,
|
||||
platform: 'win32',
|
||||
});
|
||||
|
||||
const cmdShimPath = path.join(tmpDir, 'hooks', 'gsd-check-update.cmd');
|
||||
assert.ok(
|
||||
fs.existsSync(cmdShimPath),
|
||||
`win32: .cmd shim must be written at ${cmdShimPath}`,
|
||||
);
|
||||
// File must be non-empty — structure check only (IR-first discipline)
|
||||
const size = fs.statSync(cmdShimPath).size;
|
||||
assert.ok(size > 0, '.cmd shim must have non-zero content');
|
||||
});
|
||||
|
||||
test('non-Windows (darwin): hooks.json command is "node.exe script.js" (no .cmd shim)', () => {
|
||||
const fakeRunner = '"/usr/local/bin/node"';
|
||||
|
||||
const result = ensureCodexHooksJsonSessionStart(tmpDir, {
|
||||
absoluteRunner: fakeRunner,
|
||||
platform: 'darwin',
|
||||
});
|
||||
|
||||
assert.ok(result.wrote || result.changed, 'must write hooks.json on darwin');
|
||||
|
||||
const hooksJson = JSON.parse(
|
||||
fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8'),
|
||||
);
|
||||
// #1348: hooks.json is now always written in nested { hooks: { ... } } shape
|
||||
const commands = hookHandlersForEvent(hooksJson, 'SessionStart')
|
||||
.map((h) => h && h.command)
|
||||
.filter((c) => typeof c === 'string');
|
||||
|
||||
const cmd = commands.find((c) => c.includes('gsd-check-update'));
|
||||
assert.ok(cmd, 'must have a gsd-check-update hook command on darwin');
|
||||
|
||||
// Counter-test: darwin must NOT use a .cmd shim
|
||||
assert.ok(
|
||||
!cmd.endsWith('.cmd'),
|
||||
`darwin hook command must NOT reference a .cmd shim, got: ${cmd}`,
|
||||
);
|
||||
assert.ok(
|
||||
cmd.includes('gsd-check-update.js'),
|
||||
`darwin hook command must reference the .js file directly, got: ${cmd}`,
|
||||
);
|
||||
|
||||
// .cmd shim must NOT be written on darwin
|
||||
const cmdShimPath = path.join(tmpDir, 'hooks', 'gsd-check-update.cmd');
|
||||
assert.ok(
|
||||
!fs.existsSync(cmdShimPath),
|
||||
'darwin must NOT write a .cmd shim',
|
||||
);
|
||||
});
|
||||
|
||||
test('non-Windows (linux): same as darwin — no .cmd shim', () => {
|
||||
const fakeRunner = '"/usr/local/bin/node"';
|
||||
|
||||
ensureCodexHooksJsonSessionStart(tmpDir, {
|
||||
absoluteRunner: fakeRunner,
|
||||
platform: 'linux',
|
||||
});
|
||||
|
||||
const hooksJson = JSON.parse(
|
||||
fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8'),
|
||||
);
|
||||
// #1348: hooks.json is now always written in nested { hooks: { ... } } shape
|
||||
const commands = hookHandlersForEvent(hooksJson, 'SessionStart')
|
||||
.map((h) => h && h.command)
|
||||
.filter((c) => typeof c === 'string');
|
||||
|
||||
const cmd = commands.find((c) => c.includes('gsd-check-update'));
|
||||
assert.ok(cmd, 'linux must have a gsd-check-update hook command');
|
||||
assert.ok(!cmd.endsWith('.cmd'), 'linux must NOT use a .cmd shim');
|
||||
|
||||
const cmdShimPath = path.join(tmpDir, 'hooks', 'gsd-check-update.cmd');
|
||||
assert.ok(!fs.existsSync(cmdShimPath), 'linux must NOT write a .cmd shim');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Step 5: Uninstall cleanup — .cmd shim removed from disk ─────────────────
|
||||
|
||||
describe('#3426 uninstall: gsd-check-update.cmd is removed from hooks dir on uninstall', () => {
|
||||
let tmpDir;
|
||||
|
||||
function withCodexHome(dir, fn) {
|
||||
const prev = process.env.CODEX_HOME;
|
||||
process.env.CODEX_HOME = dir;
|
||||
try { return fn(); }
|
||||
finally {
|
||||
if (prev == null) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = prev;
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-3426-uninstall-');
|
||||
fs.mkdirSync(path.join(tmpDir, 'hooks'), { recursive: true });
|
||||
// Write the .js hook (required by install) and a pre-existing .cmd shim
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'hooks', 'gsd-check-update.js'),
|
||||
'#!/usr/bin/env node\nconsole.log("ok");\n',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'hooks', 'gsd-check-update.cmd'),
|
||||
'@ECHO OFF\r\n@SETLOCAL\r\n@"C:/node.exe" "C:/path/gsd-check-update.js" %*\r\n',
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('uninstall removes gsd-check-update.cmd from hooks directory', () => {
|
||||
const cmdShimPath = path.join(tmpDir, 'hooks', 'gsd-check-update.cmd');
|
||||
assert.ok(fs.existsSync(cmdShimPath), 'pre-condition: .cmd shim exists before uninstall');
|
||||
|
||||
withCodexHome(tmpDir, () => uninstall(true, 'codex'));
|
||||
|
||||
assert.ok(
|
||||
!fs.existsSync(cmdShimPath),
|
||||
`gsd-check-update.cmd must be removed from disk on uninstall — orphaned .cmd shim would cause stale hook references. Path: ${cmdShimPath}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Step 6: Upgrade path — existing win32 hooks.json with node-runner command ─
|
||||
|
||||
describe('#3426 upgrade: reinstall on win32 migrates existing "node script.js" to .cmd shim', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-3426-upgrade-');
|
||||
fs.mkdirSync(path.join(tmpDir, 'hooks'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'hooks', 'gsd-check-update.js'),
|
||||
'#!/usr/bin/env node\nconsole.log("ok");\n',
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('replaces old "node.exe script.js" command with .cmd shim on win32 reinstall', () => {
|
||||
const managedHookPath = path.join(tmpDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/');
|
||||
// Pre-existing stale hooks.json with node-runner command (v1.42.3 shape)
|
||||
const staleLegacyCommand = `"C:/Program Files/nodejs/node.exe" "${managedHookPath}"`;
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'hooks.json'),
|
||||
JSON.stringify({
|
||||
SessionStart: [{ hooks: [{ type: 'command', command: staleLegacyCommand }] }],
|
||||
}, null, 2),
|
||||
);
|
||||
|
||||
const fakeRunner = '"C:/Program Files/nodejs/node.exe"';
|
||||
ensureCodexHooksJsonSessionStart(tmpDir, {
|
||||
absoluteRunner: fakeRunner,
|
||||
platform: 'win32',
|
||||
});
|
||||
|
||||
const hooksJson = JSON.parse(fs.readFileSync(path.join(tmpDir, 'hooks.json'), 'utf8'));
|
||||
// #1348: hooks.json is now always written in nested { hooks: { ... } } shape
|
||||
const commands = hookHandlersForEvent(hooksJson, 'SessionStart')
|
||||
.map((h) => h && h.command)
|
||||
.filter((c) => typeof c === 'string');
|
||||
|
||||
const gsdCmds = commands.filter((c) => c.includes('gsd-check-update'));
|
||||
// Exactly one managed hook after migration — no duplicates
|
||||
assert.equal(gsdCmds.length, 1, `must have exactly 1 gsd-check-update command after migration, got: ${JSON.stringify(gsdCmds)}`);
|
||||
|
||||
// Must be the .cmd shim
|
||||
assert.ok(
|
||||
gsdCmds[0].includes('.cmd'),
|
||||
`migrated command must reference .cmd shim, got: ${gsdCmds[0]}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,163 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const { install, uninstall, parseTomlToObject } = require('../bin/install.js');
|
||||
const { createTempDir, cleanup, parseFrontmatter } = require('./helpers.cjs');
|
||||
|
||||
const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist');
|
||||
const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
||||
|
||||
function withCodexHome(codexHome, fn) {
|
||||
const prev = process.env.CODEX_HOME;
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
if (prev == null) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = prev;
|
||||
}
|
||||
}
|
||||
|
||||
function extractSessionStartCommandsFromHooksJson(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return [];
|
||||
const table = (value.hooks && typeof value.hooks === 'object' && !Array.isArray(value.hooks))
|
||||
? value.hooks
|
||||
: value;
|
||||
const sessionStart = Array.isArray(table.SessionStart) ? table.SessionStart : [];
|
||||
return sessionStart.flatMap((entry) => {
|
||||
const hooks = entry && Array.isArray(entry.hooks) ? entry.hooks : [];
|
||||
return hooks.map((h) => h && h.command).filter((cmd) => typeof cmd === 'string');
|
||||
});
|
||||
}
|
||||
|
||||
describe('#3427 + #3433 — Codex installer avoids duplicate skills and mixed hook representation', { concurrency: false }, () => {
|
||||
let tmpRoot;
|
||||
let codexHome;
|
||||
|
||||
beforeEach(() => {
|
||||
if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) {
|
||||
execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' });
|
||||
}
|
||||
tmpRoot = createTempDir('gsd-3427-3433-');
|
||||
codexHome = path.join(tmpRoot, '.codex');
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpRoot);
|
||||
});
|
||||
|
||||
test('regenerates managed gsd-* skill copies and preserves unrelated user skills (#3562 reverses prior #3427/#3433 behaviour)', () => {
|
||||
// Stale legacy body — fresh install must overwrite this so Codex sees the
|
||||
// current SKILL.md, not whatever was last on disk.
|
||||
const legacySkillBody = '# old managed\n';
|
||||
fs.mkdirSync(path.join(codexHome, 'skills', 'gsd-help'), { recursive: true });
|
||||
fs.writeFileSync(path.join(codexHome, 'skills', 'gsd-help', 'SKILL.md'), legacySkillBody);
|
||||
const legacyHash = crypto.createHash('sha256').update(legacySkillBody).digest('hex');
|
||||
fs.writeFileSync(path.join(codexHome, 'gsd-file-manifest.json'), JSON.stringify({
|
||||
version: 1,
|
||||
files: {
|
||||
'skills/gsd-help/SKILL.md': legacyHash,
|
||||
},
|
||||
}, null, 2));
|
||||
|
||||
fs.mkdirSync(path.join(codexHome, 'skills', 'custom-user-skill'), { recursive: true });
|
||||
fs.writeFileSync(path.join(codexHome, 'skills', 'custom-user-skill', 'SKILL.md'), '# user skill\n');
|
||||
|
||||
withCodexHome(codexHome, () => install(true, 'codex'));
|
||||
|
||||
const skillsDir = path.join(codexHome, 'skills');
|
||||
const entries = fs.existsSync(skillsDir)
|
||||
? fs.readdirSync(skillsDir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name)
|
||||
: [];
|
||||
|
||||
// #3562: $gsd-* commands are discoverable only when skills/gsd-*/SKILL.md
|
||||
// exists. The installer must regenerate (not remove) the managed gsd-*
|
||||
// directories.
|
||||
assert.equal(entries.includes('gsd-help'), true);
|
||||
const refreshedBody = fs.readFileSync(path.join(skillsDir, 'gsd-help', 'SKILL.md'), 'utf8');
|
||||
assert.notEqual(refreshedBody, legacySkillBody, 'stale legacy body must be overwritten');
|
||||
const frontmatter = parseFrontmatter(refreshedBody);
|
||||
assert.equal(frontmatter.name, 'gsd-help', 'refreshed SKILL.md frontmatter must declare name: gsd-help');
|
||||
|
||||
// Unrelated user skills are preserved — the regen scope is `gsd-*` only.
|
||||
assert.equal(entries.includes('custom-user-skill'), true);
|
||||
});
|
||||
|
||||
test('stores managed SessionStart update hook in hooks.json and removes inline gsd hook from config.toml', () => {
|
||||
const configToml = [
|
||||
'[features]',
|
||||
'codex_hooks = true',
|
||||
'',
|
||||
'[[hooks.SessionStart]]',
|
||||
'[[hooks.SessionStart.hooks]]',
|
||||
'type = "command"',
|
||||
'command = "node /tmp/legacy/.codex/hooks/gsd-check-update.js"',
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(codexHome, 'config.toml'), configToml);
|
||||
|
||||
fs.writeFileSync(path.join(codexHome, 'hooks.json'), JSON.stringify({
|
||||
SessionStart: [
|
||||
{
|
||||
hooks: [
|
||||
{ type: 'command', command: 'node "/Users/example/bin/user-hook.js"' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}, null, 2));
|
||||
|
||||
withCodexHome(codexHome, () => install(true, 'codex'));
|
||||
|
||||
const parsedToml = parseTomlToObject(fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'));
|
||||
const tomlSessionStart = parsedToml.hooks?.SessionStart ?? [];
|
||||
const tomlCommands = tomlSessionStart.flatMap((entry) =>
|
||||
(Array.isArray(entry?.hooks) ? entry.hooks : []).map((hook) => hook.command).filter((cmd) => typeof cmd === 'string')
|
||||
);
|
||||
assert.equal(tomlCommands.some((cmd) => cmd.includes('gsd-check-update.js')), false);
|
||||
|
||||
const hooksJson = JSON.parse(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8'));
|
||||
const sessionStartCommands = extractSessionStartCommandsFromHooksJson(hooksJson);
|
||||
const gsdCommands = sessionStartCommands.filter((cmd) => cmd.includes('gsd-check-update'));
|
||||
|
||||
assert.equal(gsdCommands.length, 1);
|
||||
assert.equal(sessionStartCommands.includes('node "/Users/example/bin/user-hook.js"'), true);
|
||||
});
|
||||
|
||||
test('uninstall removes managed SessionStart hook from hooks.json but preserves user hooks', () => {
|
||||
const hooksDir = path.join(codexHome, 'hooks');
|
||||
fs.mkdirSync(hooksDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(hooksDir, 'gsd-check-update.js'), '// managed hook\n');
|
||||
const managedHookPath = path.join(codexHome, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/');
|
||||
|
||||
fs.writeFileSync(path.join(codexHome, 'hooks.json'), JSON.stringify({
|
||||
SessionStart: [
|
||||
{
|
||||
hooks: [
|
||||
{ type: 'command', command: `node "${managedHookPath}"` },
|
||||
{ type: 'command', command: 'node "/Users/example/bin/user-hook.js"' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}, null, 2));
|
||||
|
||||
withCodexHome(codexHome, () => uninstall(true, 'codex'));
|
||||
|
||||
const hooksJson = JSON.parse(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8'));
|
||||
const sessionStartCommands = extractSessionStartCommandsFromHooksJson(hooksJson);
|
||||
// On Windows the managed hook is the .cmd shim path; on POSIX it is the .js node-runner command.
|
||||
// Either way the managed hook is gone after uninstall — only the user hook remains.
|
||||
const gsdCommands = sessionStartCommands.filter((cmd) => cmd.includes('gsd-check-update'));
|
||||
|
||||
assert.equal(gsdCommands.length, 0);
|
||||
assert.equal(sessionStartCommands.includes('node "/Users/example/bin/user-hook.js"'), true);
|
||||
});
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Regression test for bug #3562 — Codex global install must create a
|
||||
* discoverable $gsd-* skill surface.
|
||||
*
|
||||
* Codex CLI 0.130.0 (the version in the issue report) does NOT auto-discover
|
||||
* commands from gsd-core/workflows/*.md or agents/*.md. It only registers
|
||||
* commands from skills/<name>/SKILL.md. Prior installer logic ("Codex now
|
||||
* discovers official skills from .agents/skills") was based on an assumption
|
||||
* that does not match the shipping Codex CLI behavior, leaving users with
|
||||
* workflows on disk and no $gsd-* entrypoints after `npx @opengsd/gsd-core
|
||||
* --codex --global`.
|
||||
*
|
||||
* Fix: re-wire copyCommandsAsCodexSkills() back into the install dispatch path
|
||||
* so the same skill-shape that Claude / Copilot / Antigravity / Cursor /
|
||||
* Windsurf / Augment / Trae installs produce is also produced for Codex.
|
||||
*/
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const { install } = require('../bin/install.js');
|
||||
const { createTempDir, cleanup, parseFrontmatter } = require('./helpers.cjs');
|
||||
|
||||
const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist');
|
||||
const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
||||
|
||||
function withCodexHome(codexHome, fn) {
|
||||
const prev = process.env.CODEX_HOME;
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
if (prev == null) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = prev;
|
||||
}
|
||||
}
|
||||
|
||||
describe('#3562 — Codex install produces discoverable $gsd-* skill surface', { concurrency: false }, () => {
|
||||
let tmpRoot;
|
||||
let codexHome;
|
||||
|
||||
beforeEach(() => {
|
||||
if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) {
|
||||
execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' });
|
||||
}
|
||||
tmpRoot = createTempDir('gsd-3562-');
|
||||
codexHome = path.join(tmpRoot, '.codex');
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpRoot);
|
||||
});
|
||||
|
||||
test('global install creates skills/gsd-help/SKILL.md', () => {
|
||||
withCodexHome(codexHome, () => install(true, 'codex'));
|
||||
|
||||
const skillPath = path.join(codexHome, 'skills', 'gsd-help', 'SKILL.md');
|
||||
assert.ok(
|
||||
fs.existsSync(skillPath),
|
||||
`Codex install must create ${skillPath} so $gsd-help is discoverable. ` +
|
||||
'Without this, Codex CLI 0.130.0 does not expose any $gsd-* command.',
|
||||
);
|
||||
});
|
||||
|
||||
test('SKILL.md content has frontmatter expected by Codex skill discovery', () => {
|
||||
withCodexHome(codexHome, () => install(true, 'codex'));
|
||||
|
||||
const skillPath = path.join(codexHome, 'skills', 'gsd-help', 'SKILL.md');
|
||||
assert.ok(fs.existsSync(skillPath), 'precondition: SKILL.md exists');
|
||||
|
||||
const content = fs.readFileSync(skillPath, 'utf8');
|
||||
const frontmatter = parseFrontmatter(content);
|
||||
assert.equal(frontmatter.name, 'gsd-help', 'SKILL.md frontmatter must declare name: gsd-help so $gsd-help resolves');
|
||||
});
|
||||
|
||||
test('multiple core $gsd-* skills are produced (not just gsd-help)', () => {
|
||||
withCodexHome(codexHome, () => install(true, 'codex'));
|
||||
|
||||
const skillsDir = path.join(codexHome, 'skills');
|
||||
assert.ok(fs.existsSync(skillsDir), 'skills/ directory must exist after install');
|
||||
|
||||
const gsdSkills = fs
|
||||
.readdirSync(skillsDir, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory() && e.name.startsWith('gsd-'))
|
||||
.map((e) => e.name);
|
||||
|
||||
// Lower bound — exact count depends on the current command surface. The
|
||||
// commands/gsd/ directory holds dozens of *.md files; expecting more than
|
||||
// 10 generated skills is a conservative floor that catches "we generated
|
||||
// nothing" or "we only generated one accidentally" regressions.
|
||||
assert.ok(
|
||||
gsdSkills.length >= 10,
|
||||
`Expected >= 10 generated gsd-* skill directories, found ${gsdSkills.length}: ${gsdSkills.join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('install preserves existing user skills (does not remove unrelated dirs)', () => {
|
||||
fs.mkdirSync(path.join(codexHome, 'skills', 'custom-user-skill'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(codexHome, 'skills', 'custom-user-skill', 'SKILL.md'),
|
||||
'---\nname: custom-user-skill\n---\n# user skill\n',
|
||||
);
|
||||
|
||||
withCodexHome(codexHome, () => install(true, 'codex'));
|
||||
|
||||
const userSkill = path.join(codexHome, 'skills', 'custom-user-skill', 'SKILL.md');
|
||||
assert.ok(
|
||||
fs.existsSync(userSkill),
|
||||
'Codex install must preserve existing non-gsd user skill directories',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,202 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Regression tests for bug #3566 — Codex installer must emit canonical
|
||||
* [features].hooks (not the legacy [features].codex_hooks).
|
||||
*
|
||||
* Codex itself marks `codex_hooks` as a `legacy_key` in
|
||||
* codex-rs/features/src/legacy.rs. The canonical current feature flag is
|
||||
* `hooks`. The GSD installer was still writing `codex_hooks` on every fresh
|
||||
* install / reinstall, leaving deprecated config behind. This file pins:
|
||||
*
|
||||
* 1. Fresh install writes canonical `[features].hooks = true` and never
|
||||
* emits `codex_hooks` (section, root-dotted, or block-fallback forms).
|
||||
* 2. Reinstall over a GSD-owned section-form legacy
|
||||
* `[features].codex_hooks = true` migrates forward to
|
||||
* `[features].hooks = true` (legacy line removed); user-owned legacy
|
||||
* entries are preserved per #2760.
|
||||
* 3. Reinstall over a GSD-owned root-dotted legacy
|
||||
* `features.codex_hooks = true` migrates forward to
|
||||
* `features.hooks = true`; user-owned legacy entries are preserved.
|
||||
* 4. Reinstall over a user-owned `[features].hooks = true` (no GSD
|
||||
* ownership marker) preserves the user line; no double-write, no
|
||||
* ownership stamp.
|
||||
* 5. The `hasEnabledCodexHooksFeature` recognizer treats both canonical
|
||||
* `hooks` AND legacy `codex_hooks` as "enabled" so existing installs
|
||||
* keep working across the migration window.
|
||||
* 6. Uninstall removes either GSD-owned `hooks` or GSD-owned legacy
|
||||
* `codex_hooks`; user-owned `hooks` is preserved.
|
||||
*
|
||||
* All assertions use parseTomlToObject — never substring-match on raw TOML
|
||||
* text (per RULESET.TESTS.no-source-grep). The product surface is the
|
||||
* parsed config shape, not the file's lexical layout.
|
||||
*/
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const { install, uninstall, parseTomlToObject } = require('../bin/install.js');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist');
|
||||
const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
||||
|
||||
function withCodexHome(codexHome, fn) {
|
||||
const prev = process.env.CODEX_HOME;
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
if (prev == null) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = prev;
|
||||
}
|
||||
}
|
||||
|
||||
function readConfig(codexHome) {
|
||||
const text = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
|
||||
return { text, parsed: parseTomlToObject(text) };
|
||||
}
|
||||
|
||||
function featuresHooks(parsed) {
|
||||
return parsed?.features?.hooks;
|
||||
}
|
||||
|
||||
function featuresCodexHooks(parsed) {
|
||||
return parsed?.features?.codex_hooks;
|
||||
}
|
||||
|
||||
describe('#3566 — Codex feature flag is canonical "hooks" (not legacy "codex_hooks")', { concurrency: false }, () => {
|
||||
let tmpRoot;
|
||||
let codexHome;
|
||||
|
||||
beforeEach(() => {
|
||||
if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) {
|
||||
execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' });
|
||||
}
|
||||
tmpRoot = createTempDir('gsd-3566-');
|
||||
codexHome = path.join(tmpRoot, '.codex');
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpRoot);
|
||||
});
|
||||
|
||||
test('fresh install writes [features].hooks = true and never emits codex_hooks', () => {
|
||||
withCodexHome(codexHome, () => install(true, 'codex'));
|
||||
const { parsed } = readConfig(codexHome);
|
||||
|
||||
assert.strictEqual(
|
||||
featuresHooks(parsed),
|
||||
true,
|
||||
'fresh install must write canonical [features].hooks = true',
|
||||
);
|
||||
assert.strictEqual(
|
||||
featuresCodexHooks(parsed),
|
||||
undefined,
|
||||
'fresh install must NOT write legacy [features].codex_hooks',
|
||||
);
|
||||
});
|
||||
|
||||
test('install over a pre-existing legacy [features].codex_hooks line preserves it (user-owned, #2760 defensive)', () => {
|
||||
// A user who hand-wrote `codex_hooks = true` keeps the legacy key.
|
||||
// Codex itself maps it via the runtime legacy_key alias, so this is
|
||||
// forward-compatible without GSD rewriting user-authored content.
|
||||
const legacy = [
|
||||
'[features]',
|
||||
'codex_hooks = true',
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(codexHome, 'config.toml'), legacy);
|
||||
|
||||
withCodexHome(codexHome, () => install(true, 'codex'));
|
||||
const { parsed } = readConfig(codexHome);
|
||||
|
||||
assert.strictEqual(
|
||||
featuresCodexHooks(parsed),
|
||||
true,
|
||||
'user-owned legacy codex_hooks line must be preserved verbatim',
|
||||
);
|
||||
});
|
||||
|
||||
test('install over a pre-existing legacy root-dotted features.codex_hooks line preserves it', () => {
|
||||
const legacy = 'features.codex_hooks = true\n';
|
||||
fs.writeFileSync(path.join(codexHome, 'config.toml'), legacy);
|
||||
|
||||
withCodexHome(codexHome, () => install(true, 'codex'));
|
||||
const { parsed } = readConfig(codexHome);
|
||||
|
||||
assert.strictEqual(
|
||||
featuresCodexHooks(parsed),
|
||||
true,
|
||||
'user-owned root-dotted legacy line must be preserved verbatim',
|
||||
);
|
||||
});
|
||||
|
||||
test('reinstall preserves user-owned [features].hooks = true (no GSD ownership marker)', () => {
|
||||
const userOwned = [
|
||||
'[features]',
|
||||
'hooks = true',
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(codexHome, 'config.toml'), userOwned);
|
||||
|
||||
withCodexHome(codexHome, () => install(true, 'codex'));
|
||||
const { parsed } = readConfig(codexHome);
|
||||
|
||||
assert.strictEqual(
|
||||
featuresHooks(parsed),
|
||||
true,
|
||||
'user-owned hooks=true must be preserved',
|
||||
);
|
||||
});
|
||||
|
||||
test('uninstall removes GSD-owned canonical hooks line but preserves user-owned hooks', () => {
|
||||
// Phase 1: fresh GSD install — writes GSD-owned hooks line.
|
||||
withCodexHome(codexHome, () => install(true, 'codex'));
|
||||
const { parsed: afterInstall } = readConfig(codexHome);
|
||||
assert.strictEqual(
|
||||
featuresHooks(afterInstall),
|
||||
true,
|
||||
'precondition: install wrote canonical hooks',
|
||||
);
|
||||
|
||||
withCodexHome(codexHome, () => uninstall(true, 'codex'));
|
||||
const configPath = path.join(codexHome, 'config.toml');
|
||||
if (!fs.existsSync(configPath)) {
|
||||
// Uninstall may delete config.toml entirely when nothing user-owned
|
||||
// remains — that is the strongest possible "feature flag removed"
|
||||
// signal and counts as success.
|
||||
return;
|
||||
}
|
||||
const { parsed: afterUninstall } = readConfig(codexHome);
|
||||
assert.notStrictEqual(
|
||||
featuresHooks(afterUninstall),
|
||||
true,
|
||||
'uninstall must remove GSD-owned canonical hooks line',
|
||||
);
|
||||
});
|
||||
|
||||
test('uninstall preserves user-owned hooks=true when GSD never owned it', () => {
|
||||
const userOwned = [
|
||||
'[features]',
|
||||
'hooks = true',
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(codexHome, 'config.toml'), userOwned);
|
||||
|
||||
withCodexHome(codexHome, () => uninstall(true, 'codex'));
|
||||
const { parsed } = readConfig(codexHome);
|
||||
|
||||
assert.strictEqual(
|
||||
featuresHooks(parsed),
|
||||
true,
|
||||
'uninstall must NOT touch a hooks line GSD never claimed ownership of (#2760 defensive principle)',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,128 +0,0 @@
|
||||
/**
|
||||
* Regression test for #3571: configuration.cjs used the source
|
||||
* checkout sdk/shared path only, which breaks installed gsd-tools.cjs because
|
||||
* runtime installs copy gsd-core/ but not sdk/.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
const CONFIGURATION_CJS = path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'configuration.cjs');
|
||||
const SHARED_DIR = path.join(REPO_ROOT, 'gsd-core', 'bin', 'shared');
|
||||
|
||||
const { install } = require('../bin/install.js');
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
const makeTmpDir = () => createTempDir('gsd-3571-');
|
||||
|
||||
function silenceConsole(fn) {
|
||||
const original = {
|
||||
log: console.log,
|
||||
warn: console.warn,
|
||||
error: console.error,
|
||||
};
|
||||
console.log = () => {};
|
||||
console.warn = () => {};
|
||||
console.error = () => {};
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
console.log = original.log;
|
||||
console.warn = original.warn;
|
||||
console.error = original.error;
|
||||
}
|
||||
}
|
||||
|
||||
describe('bug #3571: configuration generated manifests resolve in install layout', () => {
|
||||
let tmpRoot;
|
||||
let savedHome;
|
||||
let savedUserProfile;
|
||||
let savedExplicitConfigDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = makeTmpDir();
|
||||
savedHome = process.env.HOME;
|
||||
// On Windows, os.homedir() reads USERPROFILE; install() resolves via it.
|
||||
savedUserProfile = process.env.USERPROFILE;
|
||||
savedExplicitConfigDir = process.env.GSD_EXPLICIT_CONFIG_DIR;
|
||||
delete process.env.GSD_EXPLICIT_CONFIG_DIR;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.HOME = savedHome;
|
||||
if (savedUserProfile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = savedUserProfile;
|
||||
if (savedExplicitConfigDir === undefined) {
|
||||
delete process.env.GSD_EXPLICIT_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.GSD_EXPLICIT_CONFIG_DIR = savedExplicitConfigDir;
|
||||
}
|
||||
cleanup(tmpRoot);
|
||||
});
|
||||
|
||||
test('co-located bin/shared manifests let configuration.cjs load without sdk/shared', () => {
|
||||
const gsdBinDir = path.join(tmpRoot, '.codex', 'gsd-core', 'bin');
|
||||
const gsdLibDir = path.join(gsdBinDir, 'lib');
|
||||
const gsdSharedDir = path.join(gsdBinDir, 'shared');
|
||||
fs.mkdirSync(gsdLibDir, { recursive: true });
|
||||
fs.mkdirSync(gsdSharedDir, { recursive: true });
|
||||
|
||||
const installedCjs = path.join(gsdLibDir, 'configuration.cjs');
|
||||
fs.copyFileSync(CONFIGURATION_CJS, installedCjs);
|
||||
fs.copyFileSync(
|
||||
path.join(SHARED_DIR, 'config-defaults.manifest.json'),
|
||||
path.join(gsdSharedDir, 'config-defaults.manifest.json')
|
||||
);
|
||||
fs.copyFileSync(
|
||||
path.join(SHARED_DIR, 'config-schema.manifest.json'),
|
||||
path.join(gsdSharedDir, 'config-schema.manifest.json')
|
||||
);
|
||||
|
||||
delete require.cache[installedCjs];
|
||||
let mod;
|
||||
assert.doesNotThrow(() => {
|
||||
mod = require(installedCjs);
|
||||
}, 'installed configuration.cjs must not require ~/.codex/sdk/shared');
|
||||
|
||||
assert.ok(mod.VALID_CONFIG_KEYS.has('workflow.plan_review_convergence'));
|
||||
});
|
||||
|
||||
test('post-install: install() copies configuration manifests to co-located bin/shared', () => {
|
||||
process.env.HOME = tmpRoot;
|
||||
process.env.USERPROFILE = tmpRoot;
|
||||
|
||||
silenceConsole(() => {
|
||||
install(true, 'codex');
|
||||
});
|
||||
|
||||
const sharedDir = path.join(tmpRoot, '.codex', 'gsd-core', 'bin', 'shared');
|
||||
for (const fileName of ['config-defaults.manifest.json', 'config-schema.manifest.json']) {
|
||||
const installedManifest = path.join(sharedDir, fileName);
|
||||
assert.ok(fs.existsSync(installedManifest), `${fileName} must be copied to ${sharedDir}`);
|
||||
assert.doesNotThrow(() => {
|
||||
JSON.parse(fs.readFileSync(installedManifest, 'utf8'));
|
||||
}, `${fileName} must be valid JSON`);
|
||||
}
|
||||
|
||||
const installedCjs = path.join(
|
||||
tmpRoot,
|
||||
'.codex',
|
||||
'gsd-core',
|
||||
'bin',
|
||||
'lib',
|
||||
'configuration.cjs'
|
||||
);
|
||||
|
||||
delete require.cache[installedCjs];
|
||||
assert.doesNotThrow(() => {
|
||||
require(installedCjs);
|
||||
}, 'post-install configuration.cjs must load from co-located manifests');
|
||||
});
|
||||
});
|
||||
@@ -1,313 +0,0 @@
|
||||
/**
|
||||
* Regression test for bug #3582 — Codex install must materialize the skill
|
||||
* surface under `~/.codex/skills/<name>/SKILL.md`.
|
||||
*
|
||||
* Background: GSD 1.42.2 reported the user-visible failure
|
||||
* > Skipped Codex skill-copy generation (Codex discovers official skills directly)
|
||||
* which left users with a "successful" install but no routable `$gsd-*`
|
||||
* entrypoints in Codex CLI 0.130.0. Codex CLI does NOT auto-discover
|
||||
* commands from `~/.codex/gsd-core/workflows/*.md` or `agents/*.md`;
|
||||
* it only registers slash commands derived from `~/.codex/skills/<name>/SKILL.md`.
|
||||
* The "Codex discovers official skills directly" assumption was wrong.
|
||||
*
|
||||
* The current installer (#3562 / current main) calls
|
||||
* `copyCommandsAsCodexSkills()` to materialize one SKILL.md per
|
||||
* commands/gsd/*.md, with Claude-flavored command frontmatter rewritten
|
||||
* into Codex skill frontmatter and the `<codex_skill_adapter>` body
|
||||
* produced by `getCodexSkillAdapterHeader()`.
|
||||
*
|
||||
* This test locks the install contract so the 1.42.2 regression cannot
|
||||
* silently come back. It asserts the full expected skill-name set
|
||||
* (deepStrictEqual, not just count), the full adapter block (using
|
||||
* the exported `getCodexSkillAdapterHeader` IR as the expected value,
|
||||
* not raw substring search), and the success/skip log invariant.
|
||||
*/
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// This assertion validates the generated adapter block that is shipped to
|
||||
// users in SKILL.md; matching exact emitted text is the contract under test.
|
||||
|
||||
'use strict';
|
||||
|
||||
// GSD_TEST_MODE neutralizes side-effecting branches (auto-detection, etc.).
|
||||
// Must be set BEFORE requiring bin/install.js; scoped to module load only
|
||||
// so downstream tests don't see it. Mirrors the bug-2760 codex harness.
|
||||
const previousGsdTestMode = process.env.GSD_TEST_MODE;
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { install, getCodexSkillAdapterHeader } = require('../bin/install.js');
|
||||
const { parseFrontmatter, createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
if (previousGsdTestMode === undefined) {
|
||||
delete process.env.GSD_TEST_MODE;
|
||||
} else {
|
||||
process.env.GSD_TEST_MODE = previousGsdTestMode;
|
||||
}
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd');
|
||||
|
||||
// Strip ANSI color codes so log assertions don't depend on TTY detection.
|
||||
function stripAnsi(s) {
|
||||
// eslint-disable-next-line no-control-regex -- \x1b (ESC) is the required leading byte of ANSI SGR color sequences; matching it is the purpose of stripping ANSI codes from captured CLI/console output
|
||||
return s.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
}
|
||||
|
||||
function assertNoBareGsdToolsInvocation(content, label) {
|
||||
const patterns = [
|
||||
/(^|\n)[ \t]*gsd-tools\s/,
|
||||
/\$\(\s*gsd-tools\s/,
|
||||
/`\s*gsd-tools\s/,
|
||||
/(?:&&|\|\||[;|])\s*gsd-tools\s/,
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
assert.doesNotMatch(
|
||||
content,
|
||||
pattern,
|
||||
`${label} must not contain a command-position bare gsd-tools invocation`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk commands/gsd/**\/*.md and return the set of skill names the installer
|
||||
* is contractually obligated to produce. Naming rule mirrors
|
||||
* `copyCommandsAsCodexSkills` in bin/install.js: nested dirs collapse to
|
||||
* `gsd-<dir>-<file>` with the .md stripped.
|
||||
*/
|
||||
function expectedSkillNames() {
|
||||
const names = new Set();
|
||||
function recurse(dir, prefix) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
recurse(path.join(dir, entry.name), `${prefix}-${entry.name}`);
|
||||
} else if (entry.name.endsWith('.md')) {
|
||||
const base = entry.name.slice(0, -3);
|
||||
names.add(`${prefix}-${base}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
recurse(COMMANDS_DIR, 'gsd');
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a Codex global install into a temp CODEX_HOME and capture stdout/stderr.
|
||||
* Cleans up codexHome on throw so a partial-install failure never leaks
|
||||
* temp directories.
|
||||
*/
|
||||
function runCodexInstallCaptured() {
|
||||
const codexHome = createTempDir('gsd-3582-codex-');
|
||||
const logs = [];
|
||||
const warnings = [];
|
||||
const origLog = console.log;
|
||||
const origWarn = console.warn;
|
||||
console.log = (...a) => { logs.push(a.join(' ')); };
|
||||
console.warn = (...a) => { warnings.push(a.join(' ')); };
|
||||
|
||||
const previousCodexHome = process.env.CODEX_HOME;
|
||||
const previousCwd = process.cwd();
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
try {
|
||||
process.chdir(ROOT);
|
||||
install(true, 'codex');
|
||||
return { codexHome, logs, warnings };
|
||||
} catch (err) {
|
||||
// Always reclaim the temp dir if install throws — otherwise the
|
||||
// describe-level afterEach can't see codexHome and it leaks.
|
||||
try { cleanup(codexHome); } catch { /* best-effort */ }
|
||||
throw err;
|
||||
} finally {
|
||||
process.chdir(previousCwd);
|
||||
console.log = origLog;
|
||||
console.warn = origWarn;
|
||||
if (previousCodexHome === undefined) {
|
||||
delete process.env.CODEX_HOME;
|
||||
} else {
|
||||
process.env.CODEX_HOME = previousCodexHome;
|
||||
}
|
||||
if (previousGsdTestMode === undefined) {
|
||||
delete process.env.GSD_TEST_MODE;
|
||||
} else {
|
||||
process.env.GSD_TEST_MODE = previousGsdTestMode;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// concurrency:false — harness mutates console.* / process.env / process.cwd().
|
||||
// Matches the convention used by tests/bug-3562-codex-install-skill-surface.test.cjs.
|
||||
describe('bug-3582: Codex global install materializes the skill surface', { concurrency: false }, () => {
|
||||
let installRun;
|
||||
|
||||
beforeEach(() => {
|
||||
installRun = runCodexInstallCaptured();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (installRun && installRun.codexHome) {
|
||||
cleanup(installRun.codexHome);
|
||||
}
|
||||
});
|
||||
|
||||
test('writes the exact expected set of gsd-*/SKILL.md skills (deepEqual on name set)', () => {
|
||||
const skillsDir = path.join(installRun.codexHome, 'skills');
|
||||
assert.ok(
|
||||
fs.existsSync(skillsDir),
|
||||
`Codex install must create ${skillsDir} (the 1.42.2 regression skipped this entirely)`,
|
||||
);
|
||||
|
||||
const actualNames = fs.readdirSync(skillsDir, { withFileTypes: true })
|
||||
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'))
|
||||
.map(e => e.name);
|
||||
|
||||
// deepStrictEqual on the sorted full set — not just count — so a
|
||||
// partial install that drops a real command and substitutes a bogus
|
||||
// same-count `gsd-*` directory cannot pass.
|
||||
const expected = [...expectedSkillNames()].sort();
|
||||
assert.deepStrictEqual(
|
||||
[...actualNames].sort(),
|
||||
expected,
|
||||
`installed Codex skills must exactly match commands/gsd/**/*.md (one skill per command)`,
|
||||
);
|
||||
|
||||
// Every skill dir contains a non-empty SKILL.md file. Empty dirs or
|
||||
// empty SKILL.md bodies would defeat Codex's slash-command
|
||||
// registration as silently as the 1.42.2 "skipped" branch did.
|
||||
for (const name of actualNames) {
|
||||
const skillMd = path.join(skillsDir, name, 'SKILL.md');
|
||||
const stat = fs.statSync(skillMd);
|
||||
assert.ok(stat.isFile(), `${skillMd} must be a regular file`);
|
||||
assert.ok(stat.size > 0, `${skillMd} must not be empty`);
|
||||
}
|
||||
});
|
||||
|
||||
test('SKILL.md frontmatter declares hyphen-form name matching the directory', () => {
|
||||
const skillsDir = path.join(installRun.codexHome, 'skills');
|
||||
const skillDirs = fs.readdirSync(skillsDir, { withFileTypes: true })
|
||||
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'))
|
||||
.map(e => e.name);
|
||||
|
||||
for (const name of skillDirs) {
|
||||
const content = fs.readFileSync(
|
||||
path.join(skillsDir, name, 'SKILL.md'),
|
||||
'utf-8',
|
||||
);
|
||||
// Uses the shared `parseFrontmatter` from tests/helpers.cjs per the
|
||||
// CONTRIBUTING.md "tests parse, never grep" convention.
|
||||
const fm = parseFrontmatter(content);
|
||||
assert.strictEqual(
|
||||
fm.name,
|
||||
name,
|
||||
`SKILL.md name field must match directory name for ${name} (got ${JSON.stringify(fm.name)})`,
|
||||
);
|
||||
assert.ok(
|
||||
typeof fm.description === 'string' && fm.description.length > 0,
|
||||
`SKILL.md description must be a non-empty string for ${name}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('SKILL.md body contains the full <codex_skill_adapter> block produced by the exported builder', () => {
|
||||
// Structural check against the production builder's output — NOT a
|
||||
// raw substring grep on the rendered file. `getCodexSkillAdapterHeader`
|
||||
// is the typed IR exported by bin/install.js (#3582 PR #3609 codex
|
||||
// review); the file on disk must contain its full output verbatim
|
||||
// (open tag, body, closing `</codex_skill_adapter>`). A truncated,
|
||||
// empty, or missing-closing-tag adapter cannot satisfy this assertion.
|
||||
const skillsDir = path.join(installRun.codexHome, 'skills');
|
||||
const skillDirs = fs.readdirSync(skillsDir, { withFileTypes: true })
|
||||
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'))
|
||||
.map(e => e.name);
|
||||
|
||||
for (const name of skillDirs) {
|
||||
const expectedAdapter = getCodexSkillAdapterHeader(name);
|
||||
// Sanity: the builder itself must produce a closed block for the
|
||||
// assertion below to be meaningful.
|
||||
assert.ok(
|
||||
expectedAdapter.startsWith('<codex_skill_adapter>'),
|
||||
`getCodexSkillAdapterHeader(${name}) must start with the opening tag`,
|
||||
);
|
||||
assert.ok(
|
||||
expectedAdapter.trimEnd().endsWith('</codex_skill_adapter>'),
|
||||
`getCodexSkillAdapterHeader(${name}) must end with the closing tag`,
|
||||
);
|
||||
|
||||
const content = fs.readFileSync(
|
||||
path.join(skillsDir, name, 'SKILL.md'),
|
||||
'utf-8',
|
||||
);
|
||||
assert.ok(
|
||||
content.includes(expectedAdapter),
|
||||
`${name}/SKILL.md must contain the full adapter block produced by getCodexSkillAdapterHeader(${name}); Codex routes $${name} via this exact body`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('representative skills named in the issue report are present', () => {
|
||||
// The bug report and triage explicitly named these. Locking them as a
|
||||
// representative set so a future dispatch / filter / profile change
|
||||
// cannot drop just the commands the original user was trying to run.
|
||||
const representative = [
|
||||
'gsd-map-codebase', // the literal command from the bug report
|
||||
'gsd-execute-phase',
|
||||
'gsd-plan-phase',
|
||||
'gsd-new-project',
|
||||
'gsd-health',
|
||||
];
|
||||
const skillsDir = path.join(installRun.codexHome, 'skills');
|
||||
for (const name of representative) {
|
||||
const skillMd = path.join(skillsDir, name, 'SKILL.md');
|
||||
assert.ok(
|
||||
fs.existsSync(skillMd),
|
||||
`${name}/SKILL.md must exist after Codex install (was unrouteable in 1.42.2)`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('installed Codex skills do not ask agents to run bare gsd-tools commands', () => {
|
||||
const skillsDir = path.join(installRun.codexHome, 'skills');
|
||||
const skillDirs = fs.readdirSync(skillsDir, { withFileTypes: true })
|
||||
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'))
|
||||
.map(e => e.name);
|
||||
|
||||
for (const name of skillDirs) {
|
||||
const content = fs.readFileSync(
|
||||
path.join(skillsDir, name, 'SKILL.md'),
|
||||
'utf-8',
|
||||
);
|
||||
assertNoBareGsdToolsInvocation(content, `${name}/SKILL.md`);
|
||||
}
|
||||
});
|
||||
|
||||
test('installer success log mentions skills/ — never claims success while skipping', () => {
|
||||
// The 1.42.2 user-visible failure mode was a successful install that
|
||||
// printed "Skipped Codex skill-copy generation (Codex discovers
|
||||
// official skills directly)" while leaving the user with no
|
||||
// entrypoints. Lock that the broken strings can NEVER coexist with a
|
||||
// success indicator. Current main prints "✓ Installed N skills".
|
||||
const cleanLogs = installRun.logs.map(stripAnsi);
|
||||
const cleanWarnings = installRun.warnings.map(stripAnsi);
|
||||
const allOutput = [...cleanLogs, ...cleanWarnings].join('\n');
|
||||
|
||||
assert.ok(
|
||||
!/Skipped Codex skill-copy generation/i.test(allOutput),
|
||||
`installer must never print "Skipped Codex skill-copy generation" (1.42.2 failure). Output:\n${allOutput}`,
|
||||
);
|
||||
assert.ok(
|
||||
!/Codex discovers official skills directly/i.test(allOutput),
|
||||
`installer must never claim "Codex discovers official skills directly" (1.42.2 incorrect assumption). Output:\n${allOutput}`,
|
||||
);
|
||||
|
||||
// Positive proof — at least one log line acknowledges the skills install.
|
||||
const hasSkillsInstalledLog = cleanLogs.some(line => /Installed\s+\d+\s+skills\s+to\s+skills\//.test(line));
|
||||
assert.ok(
|
||||
hasSkillsInstalledLog,
|
||||
`installer must print a success line of the form "Installed N skills to skills/". Logs:\n${cleanLogs.join('\n')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,408 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Regression for bug #376 — Claude-installed hook JS files ship with raw
|
||||
* /gsd:<cmd> command literals because the hook-copy loop in install.js had
|
||||
* no /gsd: → /gsd- rewrite for the claude runtime.
|
||||
*
|
||||
* Fix: the `.js` branch of the hook-copy loop now applies
|
||||
* `content.replace(/gsd:/gi, 'gsd-')` when
|
||||
* `shouldNormalizeHyphenNamespaceInAgentBody(runtime)` is true (covers
|
||||
* claude, qwen, hermes).
|
||||
*
|
||||
* Test plan:
|
||||
* 1. Claude install to tmp prefix — installed .js hook files must contain
|
||||
* no user-facing /gsd: literals (// comment occurrences exempted).
|
||||
* 2. Cursor install regression — still rewrites correctly (pre-existing
|
||||
* branch must remain intact).
|
||||
* 3. Source files in hooks/ must be byte-identical before and after both
|
||||
* installs (install-time rewrite only, no in-tree mutation).
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const INSTALL_PATH = path.join(REPO_ROOT, 'bin', 'install.js');
|
||||
const HOOKS_DIST_DIR = path.join(REPO_ROOT, 'hooks', 'dist');
|
||||
const BUILD_HOOKS_SCRIPT = path.join(REPO_ROOT, 'scripts', 'build-hooks.js');
|
||||
|
||||
/**
|
||||
* Ensure hooks/dist is populated before any suite that reads it.
|
||||
* hooks/dist/ is gitignored and only produced by `npm run build:hooks`.
|
||||
* In CI the scoped/windows test jobs do NOT run build:hooks before running
|
||||
* tests, so the first test that needs hooks/dist would fail. This mirrors
|
||||
* the pattern used in bug-3357-codex-legacy-hooks-json-migration.test.cjs.
|
||||
*/
|
||||
function ensureHooksDist() {
|
||||
if (!fs.existsSync(HOOKS_DIST_DIR) || fs.readdirSync(HOOKS_DIST_DIR).filter(f => f.endsWith('.js')).length === 0) {
|
||||
execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Run `node install.js <...args>` from cwd.
|
||||
* GSD_TEST_MODE is cleared so the install() main block executes.
|
||||
*/
|
||||
function runInstall(cwd, args) {
|
||||
const env = { ...process.env };
|
||||
delete env.GSD_TEST_MODE;
|
||||
execFileSync(process.execPath, [INSTALL_PATH, ...args], {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env,
|
||||
timeout: 60000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an array of { rel, path } for all .js files under dir.
|
||||
*/
|
||||
function findJsFiles(dir) {
|
||||
const results = [];
|
||||
function walk(d) {
|
||||
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
|
||||
const full = path.join(d, entry.name);
|
||||
if (entry.isDirectory()) walk(full);
|
||||
else if (entry.name.endsWith('.js') || entry.name.endsWith('.cjs')) {
|
||||
results.push({ rel: path.relative(dir, full), full });
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(dir);
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a JS file's lines into comment and non-comment buckets.
|
||||
* A line is treated as a comment if it starts with optional whitespace
|
||||
* followed by // (single-line comment). Block comments are not checked
|
||||
* since none of the hook files use them for command refs.
|
||||
*/
|
||||
function nonCommentLines(content) {
|
||||
return content.split('\n').filter(line => !/^\s*\/\//.test(line));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return lines (from nonCommentLines) that contain a user-facing /gsd: ref.
|
||||
*/
|
||||
function colonRefs(content) {
|
||||
return nonCommentLines(content).filter(line => /\/gsd:/.test(line));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prerequisite: hooks/dist must exist (built by `npm run build:hooks`)
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('bug #376 — prerequisite: hooks/dist is present', () => {
|
||||
before(() => {
|
||||
// hooks/dist is gitignored; build it on demand so this test is
|
||||
// deterministic in CI scoped/windows jobs that don't pre-run build:hooks.
|
||||
ensureHooksDist();
|
||||
});
|
||||
|
||||
test('hooks/dist directory exists (run npm run build:hooks if missing)', () => {
|
||||
assert.ok(
|
||||
fs.existsSync(HOOKS_DIST_DIR),
|
||||
`hooks/dist not found at ${HOOKS_DIST_DIR}. Run: npm run build:hooks`,
|
||||
);
|
||||
});
|
||||
|
||||
test('hooks/dist contains at least one .js hook file with a /gsd: literal', () => {
|
||||
const jsFiles = findJsFiles(HOOKS_DIST_DIR);
|
||||
assert.ok(jsFiles.length > 0, 'hooks/dist must contain .js files');
|
||||
|
||||
const withColonRef = jsFiles.filter(({ full }) => {
|
||||
const content = fs.readFileSync(full, 'utf-8');
|
||||
return colonRefs(content).length > 0;
|
||||
});
|
||||
|
||||
assert.ok(
|
||||
withColonRef.length > 0,
|
||||
'Expected at least one hooks/dist .js file with a non-comment /gsd: literal ' +
|
||||
'— this confirms the test is guarding a real regression surface. ' +
|
||||
`Files checked: ${jsFiles.map(f => f.rel).join(', ')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Suite 1 — Claude install: no /gsd: colon refs in installed .js hook files
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('bug #376 — Suite 1: Claude install rewrites /gsd: → /gsd- in hook .js files', () => {
|
||||
let tmpDir;
|
||||
|
||||
before(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-376-claude-'));
|
||||
runInstall(tmpDir, ['--claude', '--local', '--no-sdk']);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('1a: hooks/ directory is created by the Claude local install', () => {
|
||||
const hooksDir = path.join(tmpDir, '.claude', 'hooks');
|
||||
assert.ok(
|
||||
fs.existsSync(hooksDir),
|
||||
`hooks/ must be created at ${hooksDir} by Claude local install`,
|
||||
);
|
||||
});
|
||||
|
||||
test('1b: installed .js hook files contain no user-facing /gsd: colon refs', () => {
|
||||
const hooksDir = path.join(tmpDir, '.claude', 'hooks');
|
||||
if (!fs.existsSync(hooksDir)) {
|
||||
// If hooks/ wasn't created (hooks/dist missing at install time), skip gracefully
|
||||
return;
|
||||
}
|
||||
|
||||
const jsFiles = findJsFiles(hooksDir);
|
||||
assert.ok(jsFiles.length > 0, 'At least one .js hook file must be installed');
|
||||
|
||||
const offenders = [];
|
||||
for (const { rel, full } of jsFiles) {
|
||||
const content = fs.readFileSync(full, 'utf-8');
|
||||
const badLines = colonRefs(content);
|
||||
if (badLines.length > 0) {
|
||||
offenders.push({ rel, lines: badLines });
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
offenders,
|
||||
[],
|
||||
'Installed Claude hook .js files must not contain /gsd:<cmd> colon refs ' +
|
||||
'(non-comment occurrences). The install-time rewriter must replace these with /gsd-<cmd>. ' +
|
||||
'Offenders: ' + JSON.stringify(offenders, null, 2),
|
||||
);
|
||||
});
|
||||
|
||||
test('1c: installed .js hook files DO contain the hyphen form /gsd- (rewrite happened)', () => {
|
||||
const hooksDir = path.join(tmpDir, '.claude', 'hooks');
|
||||
if (!fs.existsSync(hooksDir)) return;
|
||||
|
||||
const jsFiles = findJsFiles(hooksDir);
|
||||
const withHyphen = jsFiles.filter(({ full }) => {
|
||||
const content = fs.readFileSync(full, 'utf-8');
|
||||
return /\/gsd-/.test(content);
|
||||
});
|
||||
|
||||
assert.ok(
|
||||
withHyphen.length > 0,
|
||||
'At least one installed .js hook file must contain /gsd- (confirming rewrite ran). ' +
|
||||
`Files checked: ${jsFiles.map(f => f.rel).join(', ')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Suite 2 — Cursor install regression: /gsd: → /gsd- still works (pre-existing)
|
||||
//
|
||||
// Note: Cursor installs its own hooks (gsd-cursor-session-start.js and
|
||||
// gsd-cursor-post-tool.js) via the cursor-hooks-json installSurface (issue #777).
|
||||
// It does NOT install the bundled Claude-style hooks/dist files (no gsd-session-state.sh
|
||||
// etc.). The Cursor /gsd: rewrite applies in `copyWithPathReplacement` to JS files
|
||||
// under the agent/skill tree (.cursor/gsd-core/*.js etc). We verify that Cursor's
|
||||
// installed .js files under .cursor/ have no /gsd: colon refs, and that the hooks/
|
||||
// directory contains only the Cursor-specific managed hooks.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('bug #376 — Suite 2: Cursor install still rewrites /gsd: → /gsd- (regression)', () => {
|
||||
let tmpDir;
|
||||
|
||||
before(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-376-cursor-'));
|
||||
runInstall(tmpDir, ['--cursor', '--local', '--no-sdk']);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('2a: .cursor/ directory is created by the Cursor local install', () => {
|
||||
const cursorDir = path.join(tmpDir, '.cursor');
|
||||
assert.ok(
|
||||
fs.existsSync(cursorDir),
|
||||
`Cursor install must create .cursor/ directory at ${cursorDir}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('2b: Cursor-installed .js files contain no user-facing /gsd: colon refs', () => {
|
||||
const cursorDir = path.join(tmpDir, '.cursor');
|
||||
if (!fs.existsSync(cursorDir)) return;
|
||||
|
||||
// Infrastructure files whose /gsd: occurrences are intentional implementation
|
||||
// details — NOT user-facing command references that Cursor would invoke.
|
||||
//
|
||||
// scripts/fix-slash-commands.cjs is the slash-command rewriter engine, required
|
||||
// by gsd-core/bin/lib/command-roster.cjs on ALL runtimes (including Cursor).
|
||||
// It must be installed verbatim and must NOT be content-rewritten: it needs to
|
||||
// emit `/gsd:${cmd}` for non-Cursor runtimes, and its /gsd: strings are internal
|
||||
// implementation/docs (transform patterns, regex literals, template literals),
|
||||
// not commands a Cursor user would type. Rewriting it would corrupt the transformer.
|
||||
const INFRA_BASENAMES = new Set(['fix-slash-commands.cjs']);
|
||||
|
||||
const jsFiles = findJsFiles(cursorDir);
|
||||
// Cursor may not install any .js files depending on what agent/skill content exists;
|
||||
// if none, skip gracefully.
|
||||
if (jsFiles.length === 0) return;
|
||||
|
||||
const offenders = [];
|
||||
for (const { rel, full } of jsFiles) {
|
||||
// Skip infrastructure files whose /gsd: strings are intentional (see above).
|
||||
if (INFRA_BASENAMES.has(path.basename(full))) continue;
|
||||
const content = fs.readFileSync(full, 'utf-8');
|
||||
const badLines = colonRefs(content);
|
||||
if (badLines.length > 0) {
|
||||
offenders.push({ rel, lines: badLines });
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
offenders,
|
||||
[],
|
||||
'Cursor-installed .js files must not contain /gsd:<cmd> colon refs. ' +
|
||||
'The existing Cursor branch in copyWithPathReplacement must still apply /gsd:/gi → gsd- rewrite. ' +
|
||||
'Offenders: ' + JSON.stringify(offenders, null, 2),
|
||||
);
|
||||
});
|
||||
|
||||
test('2c: Cursor install creates a hooks/ directory with only Cursor-specific managed hooks', () => {
|
||||
// Since issue #777, Cursor installs gsd-cursor-session-start.js and
|
||||
// gsd-cursor-post-tool.js into <configDir>/hooks/. These are Cursor-native
|
||||
// hooks — NOT the bundled Claude-style hooks (no gsd-session-state.sh etc.).
|
||||
// Verify: hooks/ exists AND does NOT contain any Claude-bundled hooks.
|
||||
const hooksDir = path.join(tmpDir, '.cursor', 'hooks');
|
||||
assert.ok(
|
||||
fs.existsSync(hooksDir),
|
||||
'Cursor install must create a hooks/ directory for its managed hook scripts (#777)',
|
||||
);
|
||||
const CLAUDE_BUNDLED_HOOKS = ['gsd-session-state.sh', 'gsd-context-monitor.js', 'gsd-statusline.js'];
|
||||
for (const hook of CLAUDE_BUNDLED_HOOKS) {
|
||||
assert.strictEqual(
|
||||
fs.existsSync(path.join(hooksDir, hook)),
|
||||
false,
|
||||
`Cursor hooks/ must NOT contain Claude-bundled hook ${hook} — only Cursor-native hooks are installed`,
|
||||
);
|
||||
}
|
||||
// The two Cursor-specific managed hooks must be present.
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(hooksDir, 'gsd-cursor-session-start.js')),
|
||||
'gsd-cursor-session-start.js must be installed in .cursor/hooks/ (#777)',
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(hooksDir, 'gsd-cursor-post-tool.js')),
|
||||
'gsd-cursor-post-tool.js must be installed in .cursor/hooks/ (#777)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Suite 3 — Source files in hooks/ are untouched
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('bug #376 — Suite 3: hooks/ source files are unchanged by install', () => {
|
||||
let snapshotBefore;
|
||||
|
||||
before(() => {
|
||||
// Ensure hooks/dist is built before snapshotting; it may be absent in CI
|
||||
// scoped/windows jobs that don't pre-run build:hooks (#777 fix).
|
||||
ensureHooksDist();
|
||||
// Snapshot hooks/dist JS files before any install in this suite
|
||||
snapshotBefore = {};
|
||||
if (fs.existsSync(HOOKS_DIST_DIR)) {
|
||||
for (const { rel, full } of findJsFiles(HOOKS_DIST_DIR)) {
|
||||
snapshotBefore[rel] = fs.readFileSync(full, 'utf-8');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('3a: hooks/dist .js source files still contain /gsd: literals (not mutated)', () => {
|
||||
// The source must remain in colon form — the rewrite is install-time only
|
||||
const jsFiles = findJsFiles(HOOKS_DIST_DIR);
|
||||
const withColonRef = jsFiles.filter(({ full }) => {
|
||||
const content = fs.readFileSync(full, 'utf-8');
|
||||
return colonRefs(content).length > 0;
|
||||
});
|
||||
|
||||
// We know from the prerequisite suite that at least one file had a colon ref;
|
||||
// if the source was mutated by install, this would now be zero.
|
||||
assert.ok(
|
||||
withColonRef.length > 0,
|
||||
'hooks/dist .js files must still contain /gsd: literals after install — ' +
|
||||
'the install-time rewrite must NOT modify the source tree. ' +
|
||||
`Files that still have colon refs: ${withColonRef.map(f => f.rel).join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('3b: hooks/dist .js source file contents match pre-test snapshot (byte-identical)', () => {
|
||||
if (Object.keys(snapshotBefore).length === 0) {
|
||||
// hooks/dist was absent before; skip
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [rel, before] of Object.entries(snapshotBefore)) {
|
||||
const full = path.join(HOOKS_DIST_DIR, rel);
|
||||
const after = fs.readFileSync(full, 'utf-8');
|
||||
assert.strictEqual(
|
||||
after,
|
||||
before,
|
||||
`hooks/dist/${rel} was mutated by install — install must only rewrite the installed copy, not the source`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Suite 4 — Pure-function: shouldNormalizeHyphenNamespaceInAgentBody covers claude
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('bug #376 — Suite 4: shouldNormalizeHyphenNamespaceInAgentBody covers claude', () => {
|
||||
const install = require(INSTALL_PATH);
|
||||
|
||||
test('4a: shouldNormalizeHyphenNamespaceInAgentBody is exported', () => {
|
||||
assert.strictEqual(
|
||||
typeof install.shouldNormalizeHyphenNamespaceInAgentBody,
|
||||
'function',
|
||||
'install.js must export shouldNormalizeHyphenNamespaceInAgentBody',
|
||||
);
|
||||
});
|
||||
|
||||
test('4b: claude is in the hyphen-namespace set', () => {
|
||||
assert.strictEqual(
|
||||
install.shouldNormalizeHyphenNamespaceInAgentBody('claude'),
|
||||
true,
|
||||
'claude must be a hyphen-namespace runtime',
|
||||
);
|
||||
});
|
||||
|
||||
test('4c: qwen is in the hyphen-namespace set', () => {
|
||||
assert.strictEqual(
|
||||
install.shouldNormalizeHyphenNamespaceInAgentBody('qwen'),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('4d: hermes is in the hyphen-namespace set', () => {
|
||||
assert.strictEqual(
|
||||
install.shouldNormalizeHyphenNamespaceInAgentBody('hermes'),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('4e: gemini is NOT in the hyphen-namespace set', () => {
|
||||
assert.strictEqual(
|
||||
install.shouldNormalizeHyphenNamespaceInAgentBody('gemini'),
|
||||
false,
|
||||
'gemini intentionally keeps colon namespace and must not be in the hyphen set',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,155 +0,0 @@
|
||||
/**
|
||||
* Regression test for bug #3808.
|
||||
*
|
||||
* When Codex runs in Default mode, `request_user_input` is reported as
|
||||
* unavailable. The Codex skill adapter must tell the agent to activate the
|
||||
* workflow's built-in TEXT_MODE mechanism (`--text` flag) rather than either:
|
||||
* (a) silently picking a default value — the #3018 failure mode, or
|
||||
* (b) ad-hoc plain-text fallback that bypasses the workflow's own branching.
|
||||
*
|
||||
* Workflows (e.g. plan-phase.md) already have TEXT_MODE logic:
|
||||
* "Set TEXT_MODE=true if `--text` is present in $ARGUMENTS OR text_mode
|
||||
* from init JSON is true."
|
||||
* The adapter must tell the agent to USE that mechanism when
|
||||
* `request_user_input` is unavailable instead of inventing its own fallback
|
||||
* or silently continuing with defaults.
|
||||
*
|
||||
* Test design: mirrors the typed-semantic-flag pattern from bug #3018 so that
|
||||
* prose rewording doesn't break tests as long as the semantics stay correct.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
|
||||
const INSTALL = require(path.join(__dirname, '..', 'bin', 'install.js'));
|
||||
const { getCodexSkillAdapterHeader } = INSTALL;
|
||||
|
||||
/**
|
||||
* Extract the "Execute mode fallback" section text from the adapter header.
|
||||
* Returns null if the section is missing. Section runs from the
|
||||
* "Execute mode fallback:" label up to the next heading or </codex_skill_adapter> tag.
|
||||
*/
|
||||
function extractExecuteModeFallback(header) {
|
||||
const m = header.match(/Execute mode fallback:\s*\n([\s\S]*?)(?=\n##\s|\n<\/codex_skill_adapter>)/);
|
||||
return m ? m[1].trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the Execute-mode-fallback section into a typed semantic-flag record.
|
||||
*
|
||||
* Flags for bug #3808 (TEXT_MODE activation):
|
||||
* activatesTextMode — does the prose tell the agent to activate TEXT_MODE / use --text?
|
||||
* instructsStop — does the prose tell the agent to stop/halt/wait?
|
||||
* presentsPlainText — does the prose mention plain-text / numbered-list presentation?
|
||||
* silentlyPicksDefaults — (anti-pattern) does the prose instruct silent-default picking?
|
||||
*/
|
||||
function parseExecuteModeFallbackFor3808(section) {
|
||||
if (!section || typeof section !== 'string') {
|
||||
return {
|
||||
ok: false,
|
||||
sectionLength: 0,
|
||||
activatesTextMode: false,
|
||||
instructsStop: false,
|
||||
presentsPlainText: false,
|
||||
silentlyPicksDefaults: false,
|
||||
};
|
||||
}
|
||||
|
||||
const lower = section.toLowerCase();
|
||||
|
||||
// (a) TEXT_MODE activation — adapter must tell the agent to use the workflow's
|
||||
// built-in text mode mechanism when request_user_input is unavailable.
|
||||
// Accept either: explicit "--text" flag mention OR "text_mode" / "text mode"
|
||||
// paired with context showing it is being SET/ACTIVATED (not just referenced).
|
||||
const mentionsTextFlag = section.includes('--text');
|
||||
const mentionsTextModeOn = /text_mode\s*=\s*true|set\s+text_mode|activate\s+text.?mode|enable\s+text.?mode|text.?mode.*active|text.?mode.*on\b/i.test(section);
|
||||
const activatesTextMode = mentionsTextFlag || mentionsTextModeOn;
|
||||
|
||||
// (b) STOP/WAIT directive — the agent must halt instead of proceeding silently.
|
||||
const instructsStop = /\b(stop|halt|wait)\b/.test(lower);
|
||||
|
||||
// (c) Plain-text fallback presentation.
|
||||
const presentsPlainText = /plain.?text|numbered list/.test(lower);
|
||||
|
||||
// Anti-pattern guard — the prose that caused #3018 and resurfaces in #3808.
|
||||
const silentlyPicksDefaults = /pick (a |the )?(reasonable|sensible|sane) default/i.test(section);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
sectionLength: section.length,
|
||||
activatesTextMode,
|
||||
instructsStop,
|
||||
presentsPlainText,
|
||||
silentlyPicksDefaults,
|
||||
};
|
||||
}
|
||||
|
||||
describe('bug #3808: codex skill adapter activates TEXT_MODE when request_user_input is unavailable', () => {
|
||||
const SKILL_NAMES = ['gsd-plan-phase', 'gsd-discuss-phase', 'gsd-execute-phase', 'gsd-verify-work'];
|
||||
|
||||
test('getCodexSkillAdapterHeader is exported', () => {
|
||||
assert.equal(typeof getCodexSkillAdapterHeader, 'function');
|
||||
});
|
||||
|
||||
test('Execute mode fallback section exists for all key skills', () => {
|
||||
for (const skillName of SKILL_NAMES) {
|
||||
const header = getCodexSkillAdapterHeader(skillName);
|
||||
const section = extractExecuteModeFallback(header);
|
||||
assert.ok(section !== null && section.length > 0,
|
||||
`${skillName}: Execute mode fallback section must exist and have content`);
|
||||
}
|
||||
});
|
||||
|
||||
for (const skillName of SKILL_NAMES) {
|
||||
test(`${skillName}: fallback activates TEXT_MODE (--text flag or text_mode=true) when request_user_input is unavailable`, () => {
|
||||
const header = getCodexSkillAdapterHeader(skillName);
|
||||
const section = extractExecuteModeFallback(header);
|
||||
const parsed = parseExecuteModeFallbackFor3808(section);
|
||||
assert.equal(parsed.activatesTextMode, true,
|
||||
`${skillName}: fallback must instruct the agent to activate TEXT_MODE (mention --text flag or text_mode=true/active) when request_user_input is unavailable (#3808). Section was:\n${section}`);
|
||||
});
|
||||
|
||||
test(`${skillName}: fallback instructs STOP/WAIT (not silent continuation)`, () => {
|
||||
const header = getCodexSkillAdapterHeader(skillName);
|
||||
const section = extractExecuteModeFallback(header);
|
||||
const parsed = parseExecuteModeFallbackFor3808(section);
|
||||
assert.equal(parsed.instructsStop, true,
|
||||
`${skillName}: fallback must include stop/halt/wait instruction. Section was:\n${section}`);
|
||||
});
|
||||
|
||||
test(`${skillName}: fallback does NOT contain silent-default anti-pattern`, () => {
|
||||
const header = getCodexSkillAdapterHeader(skillName);
|
||||
const section = extractExecuteModeFallback(header);
|
||||
const parsed = parseExecuteModeFallbackFor3808(section);
|
||||
assert.equal(parsed.silentlyPicksDefaults, false,
|
||||
`${skillName}: regression — fallback must NOT instruct the agent to pick defaults autonomously (#3018 / #3808). Section was:\n${section}`);
|
||||
});
|
||||
}
|
||||
|
||||
test('typed semantic-record snapshot for gsd-plan-phase — full contract', () => {
|
||||
const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-plan-phase'));
|
||||
const parsed = parseExecuteModeFallbackFor3808(section);
|
||||
assert.deepStrictEqual(
|
||||
{
|
||||
ok: parsed.ok,
|
||||
activatesTextMode: parsed.activatesTextMode,
|
||||
instructsStop: parsed.instructsStop,
|
||||
presentsPlainText: parsed.presentsPlainText,
|
||||
silentlyPicksDefaults: parsed.silentlyPicksDefaults,
|
||||
},
|
||||
{
|
||||
ok: true,
|
||||
activatesTextMode: true,
|
||||
instructsStop: true,
|
||||
presentsPlainText: true,
|
||||
silentlyPicksDefaults: false,
|
||||
},
|
||||
`gsd-plan-phase: full TEXT_MODE fallback contract violated (#3808). Section was:\n${section}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,293 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Bug #410: finishInstall writes ~/.gsd/defaults.json for non-Claude runtimes
|
||||
* without a GSD_TEST_MODE guard, polluting the real developer home directory
|
||||
* during test runs.
|
||||
*
|
||||
* The opencode permission-config write a few lines above already carries the
|
||||
* GSD_TEST_MODE guard (added for #130) — this test covers the un-fixed sibling
|
||||
* (the resolve_model_ids: "omit" write).
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
|
||||
// Point HOME at a temp dir so the defaults.json write can't reach the real
|
||||
// ~/.gsd/ even if the guard is missing.
|
||||
// On Windows, os.homedir() reads USERPROFILE (not HOME). Set both so
|
||||
// finishInstall's path.join(os.homedir(), '.gsd') resolves into FAKE_HOME
|
||||
// on every platform. Node docs: https://nodejs.org/docs/latest-v22.x/api/os.html#oshomedir
|
||||
const FAKE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-410-test-'));
|
||||
process.env.HOME = FAKE_HOME;
|
||||
process.env.USERPROFILE = FAKE_HOME;
|
||||
|
||||
// The path that finishInstall would write to for a non-Claude runtime.
|
||||
const GSD_DIR = path.join(FAKE_HOME, '.gsd');
|
||||
const DEFAULTS_PATH = path.join(GSD_DIR, 'defaults.json');
|
||||
|
||||
// Set GSD_TEST_MODE before requiring install.js so any module-level guards
|
||||
// also see the flag.
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const installModule = require(path.join(ROOT, 'bin', 'install.js'));
|
||||
|
||||
// A synthetic settingsPath that won't exist — finishInstall should cope.
|
||||
const SETTINGS_PATH = path.join(FAKE_HOME, `gsd-test-settings-${process.pid}.json`);
|
||||
|
||||
function callFinishInstallForRuntime(runtime) {
|
||||
const original = console.log;
|
||||
console.log = () => {};
|
||||
try {
|
||||
installModule.finishInstall(
|
||||
SETTINGS_PATH,
|
||||
{}, // empty settings
|
||||
null, // statuslineCommand
|
||||
false, // shouldInstallStatusline
|
||||
runtime,
|
||||
true, // isGlobal
|
||||
null, // configDir
|
||||
);
|
||||
} finally {
|
||||
console.log = original;
|
||||
}
|
||||
}
|
||||
|
||||
describe('Bug #410: finishInstall non-Claude runtime + GSD_TEST_MODE side-effect guard', () => {
|
||||
test('defaults.json is NOT written for opencode runtime under GSD_TEST_MODE', () => {
|
||||
assert.equal(
|
||||
fs.existsSync(DEFAULTS_PATH),
|
||||
false,
|
||||
'defaults.json should not exist before finishInstall call',
|
||||
);
|
||||
|
||||
callFinishInstallForRuntime('opencode');
|
||||
|
||||
assert.equal(
|
||||
fs.existsSync(DEFAULTS_PATH),
|
||||
false,
|
||||
`defaults.json must NOT be created under GSD_TEST_MODE; found at ${DEFAULTS_PATH}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('defaults.json is NOT written for gemini runtime under GSD_TEST_MODE', () => {
|
||||
// Reset in case previous test left artifacts (it shouldn't).
|
||||
assert.equal(
|
||||
fs.existsSync(DEFAULTS_PATH),
|
||||
false,
|
||||
'defaults.json should not exist before gemini test',
|
||||
);
|
||||
|
||||
callFinishInstallForRuntime('gemini');
|
||||
|
||||
assert.equal(
|
||||
fs.existsSync(DEFAULTS_PATH),
|
||||
false,
|
||||
`defaults.json must NOT be created under GSD_TEST_MODE for gemini; found at ${DEFAULTS_PATH}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('defaults.json IS written for opencode runtime when GSD_TEST_MODE is unset', () => {
|
||||
// Temporarily unset GSD_TEST_MODE to verify the user-facing path still works.
|
||||
const saved = process.env.GSD_TEST_MODE;
|
||||
delete process.env.GSD_TEST_MODE;
|
||||
try {
|
||||
callFinishInstallForRuntime('opencode');
|
||||
assert.equal(
|
||||
fs.existsSync(DEFAULTS_PATH),
|
||||
true,
|
||||
`defaults.json must be written for non-Claude runtime when GSD_TEST_MODE is unset`,
|
||||
);
|
||||
// Verify the written content is correct.
|
||||
const contents = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8'));
|
||||
assert.equal(contents.resolve_model_ids, 'omit', 'resolve_model_ids must be "omit"');
|
||||
} finally {
|
||||
// Restore GSD_TEST_MODE and clean up the written file.
|
||||
process.env.GSD_TEST_MODE = saved;
|
||||
cleanup(DEFAULTS_PATH);
|
||||
try { fs.rmdirSync(GSD_DIR); } catch { /* not empty or already gone */ }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Bug #1569 folded here (sibling on the SAME finishInstall resolve_model_ids block):
|
||||
// the #1156 default-to-"omit" step keyed its write on `!== "omit"`, so an explicit
|
||||
// `resolve_model_ids: true` opt-in (resolveModelInternal returns full materialized
|
||||
// model IDs) was silently clobbered across all 14 non-Claude runtimes. The fix
|
||||
// preserves `true` and only defaults absent/falsy → "omit". Reuses the #410 harness.
|
||||
|
||||
describe('Bug #1569: non-Claude finishInstall preserves explicit resolve_model_ids:true', () => {
|
||||
function seedDefaults(obj) {
|
||||
fs.mkdirSync(GSD_DIR, { recursive: true });
|
||||
fs.writeFileSync(DEFAULTS_PATH, JSON.stringify(obj, null, 2) + '\n', 'utf8');
|
||||
}
|
||||
|
||||
function withUserPath(fn) {
|
||||
const saved = process.env.GSD_TEST_MODE;
|
||||
delete process.env.GSD_TEST_MODE;
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
process.env.GSD_TEST_MODE = saved;
|
||||
}
|
||||
}
|
||||
|
||||
test('explicit resolve_model_ids:true survives a codex global install (the reported case)', () => {
|
||||
withUserPath(() => {
|
||||
seedDefaults({ runtime: 'codex', model_profile: 'balanced', resolve_model_ids: true });
|
||||
callFinishInstallForRuntime('codex');
|
||||
const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8'));
|
||||
assert.equal(
|
||||
after.resolve_model_ids,
|
||||
true,
|
||||
'explicit resolve_model_ids:true must be preserved across a codex install, not clobbered to "omit"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// The clobber guard is runtime-agnostic (`runtime !== 'claude'`); parameterize
|
||||
// across a representative slice of non-Claude runtimes.
|
||||
for (const runtime of ['codex', 'opencode', 'gemini']) {
|
||||
test(`explicit resolve_model_ids:true survives a ${runtime} global install`, () => {
|
||||
withUserPath(() => {
|
||||
seedDefaults({ runtime, resolve_model_ids: true });
|
||||
callFinishInstallForRuntime(runtime);
|
||||
const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8'));
|
||||
assert.equal(
|
||||
after.resolve_model_ids,
|
||||
true,
|
||||
`explicit resolve_model_ids:true must be preserved for ${runtime}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('absent resolve_model_ids still defaults to "omit" (preserves #1156 intent)', () => {
|
||||
withUserPath(() => {
|
||||
seedDefaults({ runtime: 'codex' });
|
||||
callFinishInstallForRuntime('codex');
|
||||
const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8'));
|
||||
assert.equal(
|
||||
after.resolve_model_ids,
|
||||
'omit',
|
||||
'absent resolve_model_ids must still default to "omit" for non-Claude runtimes',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('explicit resolve_model_ids:false still defaults to "omit"', () => {
|
||||
withUserPath(() => {
|
||||
seedDefaults({ runtime: 'codex', resolve_model_ids: false });
|
||||
callFinishInstallForRuntime('codex');
|
||||
const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8'));
|
||||
assert.equal(after.resolve_model_ids, 'omit', 'false must still be normalized to "omit"');
|
||||
});
|
||||
});
|
||||
|
||||
test('non-canonical resolve_model_ids values (0, "", "yes", {}) default to "omit" — no Claude alias leak (#1569 codex review)', () => {
|
||||
// The domain is true/false/"omit"/absent. Any OTHER value is malformed; the safe
|
||||
// non-Claude default is "omit" (don't leak Claude aliases the runtime can't resolve).
|
||||
withUserPath(() => {
|
||||
for (const bad of [0, '', 'yes', {}]) {
|
||||
seedDefaults({ runtime: 'codex', resolve_model_ids: bad });
|
||||
callFinishInstallForRuntime('codex');
|
||||
const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8'));
|
||||
assert.equal(
|
||||
after.resolve_model_ids,
|
||||
'omit',
|
||||
`non-canonical resolve_model_ids:${JSON.stringify(bad)} must default to "omit", not pass through`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('already-"omit" is left unchanged (idempotent, no rewrite churn)', () => {
|
||||
withUserPath(() => {
|
||||
seedDefaults({ runtime: 'codex', resolve_model_ids: 'omit' });
|
||||
const beforeMtime = fs.statSync(DEFAULTS_PATH).mtimeMs;
|
||||
// fs mtime resolution can be coarse; wait briefly so an accidental rewrite is detectable.
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < 20) { /* spin briefly */ }
|
||||
callFinishInstallForRuntime('codex');
|
||||
const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8'));
|
||||
const afterMtime = fs.statSync(DEFAULTS_PATH).mtimeMs;
|
||||
assert.equal(after.resolve_model_ids, 'omit');
|
||||
assert.equal(
|
||||
afterMtime,
|
||||
beforeMtime,
|
||||
'defaults.json must not be rewritten when resolve_model_ids is already "omit" (idempotent)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('claude runtime never touches resolve_model_ids (cross-runtime parity)', () => {
|
||||
withUserPath(() => {
|
||||
seedDefaults({ runtime: 'claude', resolve_model_ids: true });
|
||||
callFinishInstallForRuntime('claude');
|
||||
const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8'));
|
||||
assert.equal(
|
||||
after.resolve_model_ids,
|
||||
true,
|
||||
'claude install must never rewrite resolve_model_ids',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('malformed defaults.json does not crash — still defaults to "omit"', () => {
|
||||
withUserPath(() => {
|
||||
fs.mkdirSync(GSD_DIR, { recursive: true });
|
||||
fs.writeFileSync(DEFAULTS_PATH, '{ not valid json }', 'utf8');
|
||||
// Must not throw.
|
||||
callFinishInstallForRuntime('codex');
|
||||
const after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8'));
|
||||
assert.equal(
|
||||
after.resolve_model_ids,
|
||||
'omit',
|
||||
'malformed defaults.json must be recovered to a valid state with resolve_model_ids:omit',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Bug #1657 — finishInstall reads ~/.gsd/defaults.json with JSON.parse but did not
|
||||
// validate the result is a plain object. A valid-JSON-but-non-object value (null, [],
|
||||
// 42, "str") bypassed the catch and flowed through, leaving the malformed file on disk
|
||||
// unrecovered (and, for null, throwing a TypeError swallowed by the outer try/catch).
|
||||
// Folded into the owning install-defaults test (no new top-level bug-NNNN file).
|
||||
describe('Bug #1657: finishInstall recovers a malformed (non-object) defaults.json', () => {
|
||||
function seedDefaultsRaw(raw) {
|
||||
fs.mkdirSync(GSD_DIR, { recursive: true });
|
||||
fs.writeFileSync(DEFAULTS_PATH, raw, 'utf8');
|
||||
}
|
||||
function runAndRead(runtime) {
|
||||
const saved = process.env.GSD_TEST_MODE;
|
||||
delete process.env.GSD_TEST_MODE;
|
||||
const log = console.log; console.log = () => {};
|
||||
let threw = null;
|
||||
try {
|
||||
installModule.finishInstall(SETTINGS_PATH, {}, null, false, runtime, true, null);
|
||||
} catch (e) { threw = e.message; } finally { console.log = log; process.env.GSD_TEST_MODE = saved; }
|
||||
let after = null;
|
||||
try { after = JSON.parse(fs.readFileSync(DEFAULTS_PATH, 'utf8')); } catch (e) { after = 'UNPARSEABLE: ' + e.message; }
|
||||
return { threw, after };
|
||||
}
|
||||
|
||||
for (const [label, raw] of [['null', 'null'], ['array', '[]'], ['number', '42'], ['string', '"oops"']]) {
|
||||
test(`seed ${label} (${raw}) recovers to a valid object with resolve_model_ids:omit`, () => {
|
||||
seedDefaultsRaw(raw);
|
||||
const { threw, after } = runAndRead('codex');
|
||||
assert.equal(threw, null, `must not throw for seed ${label} (got: ${threw})`);
|
||||
assert.equal(
|
||||
after !== null && typeof after === 'object' && !Array.isArray(after) && after.resolve_model_ids === 'omit',
|
||||
true,
|
||||
`seed ${label} must recover to { resolve_model_ids: 'omit' }, got: ${JSON.stringify(after)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
'use strict';
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
// parseConfigDirArg is not exported directly from bin/install.js (it closes
|
||||
// over the module-level `args` array). We expose a pure seam here:
|
||||
// parseConfigDirFromArgs(args) that mirrors the function's logic so we can
|
||||
// test the equals-form parsing without spawning a child process.
|
||||
//
|
||||
// The implementation under test is inlined below (RED: before the fix it will
|
||||
// reproduce the truncation bug). Once the fix lands, we swap in the real
|
||||
// implementation via require.
|
||||
|
||||
/**
|
||||
* Pure seam that replicates the equals-form parse logic from bin/install.js.
|
||||
* We import it via a thin wrapper so that the function can be tested without
|
||||
* executing the entire install script.
|
||||
*
|
||||
* During RED the bug is: `split('=')[1]` drops everything after the second `=`.
|
||||
*/
|
||||
const { parseConfigDirFromArgs } = require('../bin/install.js');
|
||||
|
||||
describe('bug-442: --config-dir= equals-form path parsing', () => {
|
||||
// ── Happy-path: single = in path ─────────────────────────────────────────
|
||||
test('--config-dir=<path> with one = in value returns full value', () => {
|
||||
const result = parseConfigDirFromArgs(['--config-dir=/tmp/gsd=a']);
|
||||
assert.equal(result, '/tmp/gsd=a');
|
||||
});
|
||||
|
||||
// ── Happy-path: multiple = in path ───────────────────────────────────────
|
||||
test('--config-dir=<path> with multiple = in value returns full value', () => {
|
||||
const result = parseConfigDirFromArgs(['--config-dir=/tmp/a=b=c']);
|
||||
assert.equal(result, '/tmp/a=b=c');
|
||||
});
|
||||
|
||||
// ── Short form -c= ────────────────────────────────────────────────────────
|
||||
test('-c=<path> with = in value returns full value', () => {
|
||||
const result = parseConfigDirFromArgs(['-c=/tmp/gsd=a']);
|
||||
assert.equal(result, '/tmp/gsd=a');
|
||||
});
|
||||
|
||||
test('-c=<path> with multiple = in value returns full value', () => {
|
||||
const result = parseConfigDirFromArgs(['-c=/tmp/a=b=c']);
|
||||
assert.equal(result, '/tmp/a=b=c');
|
||||
});
|
||||
|
||||
// ── Contract: empty value ─────────────────────────────────────────────────
|
||||
// --config-dir= (no value after the =) → returns empty string ''.
|
||||
// The caller (parseConfigDirArg) treats '' as missing and errors; the seam
|
||||
// itself should faithfully return '' rather than null/undefined so the
|
||||
// caller can make the error decision.
|
||||
test('--config-dir= with no value returns empty string', () => {
|
||||
const result = parseConfigDirFromArgs(['--config-dir=']);
|
||||
assert.equal(result, '');
|
||||
});
|
||||
|
||||
test('-c= with no value returns empty string', () => {
|
||||
const result = parseConfigDirFromArgs(['-c=']);
|
||||
assert.equal(result, '');
|
||||
});
|
||||
|
||||
// ── Space-separated form is unaffected (regression guard) ─────────────────
|
||||
test('--config-dir <path> space-separated still returns the path', () => {
|
||||
const result = parseConfigDirFromArgs(['--config-dir', '/tmp/gsd=a']);
|
||||
assert.equal(result, '/tmp/gsd=a');
|
||||
});
|
||||
|
||||
test('-c <path> space-separated still returns the path', () => {
|
||||
const result = parseConfigDirFromArgs(['-c', '/tmp/gsd=a']);
|
||||
assert.equal(result, '/tmp/gsd=a');
|
||||
});
|
||||
|
||||
// ── No config-dir flag → null ─────────────────────────────────────────────
|
||||
test('returns null when no --config-dir flag is present', () => {
|
||||
const result = parseConfigDirFromArgs(['--global', '--claude']);
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
// ── Negative matrix (CLI edge cases) ─────────────────────────────────────
|
||||
// Flag-looking value after space form: next arg starts with - → null (no
|
||||
// valid value; the real function would process.exit but the seam returns null
|
||||
// so tests stay in-process).
|
||||
test('space form with next arg being a flag returns null (flag-looking value)', () => {
|
||||
const result = parseConfigDirFromArgs(['--config-dir', '--other-flag']);
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
// Equals form where value is a path with no = (plain path, no regression)
|
||||
test('--config-dir=<plain-path> without any = in path still works', () => {
|
||||
const result = parseConfigDirFromArgs(['--config-dir=/tmp/plain']);
|
||||
assert.equal(result, '/tmp/plain');
|
||||
});
|
||||
|
||||
// Flag appears after other args (positional ordering should not matter)
|
||||
test('--config-dir= flag after other args is parsed correctly', () => {
|
||||
const result = parseConfigDirFromArgs(['--global', '--config-dir=/tmp/a=b', '--claude']);
|
||||
assert.equal(result, '/tmp/a=b');
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
/**
|
||||
* Regression guard for #505: dead SDK-shim verification subsystem removed.
|
||||
*
|
||||
* Post-ADR-0174 the `@opengsd/gsd-sdk` package was retired; `sdk/` no longer
|
||||
* ships. `installSdkIfNeeded` and all functions it transitively called are
|
||||
* dead code with no live callers. This test asserts:
|
||||
*
|
||||
* 1. All removed symbols are NO LONGER exported from bin/install.js.
|
||||
* 2. The two live stale-standalone-SDK helpers (detectStaleStandaloneSdk,
|
||||
* formatStaleStandaloneSdkWarning) are STILL exported as functions — they
|
||||
* handle a real user-facing condition (#3406) and MUST NOT be removed.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const inst = require('../bin/install.js');
|
||||
|
||||
describe('bug #505: dead SDK verification subsystem removed from bin/install.js', () => {
|
||||
// ----------------------------------------------------------------
|
||||
// Dead symbols — must NOT be exported after removal
|
||||
// ----------------------------------------------------------------
|
||||
const deadSymbols = [
|
||||
'installSdkIfNeeded',
|
||||
'classifySdkInstall',
|
||||
'buildSdkFailFastReport',
|
||||
'renderSdkFailFastReport',
|
||||
'buildGsdSdkVersionMismatchReport',
|
||||
'renderGsdSdkVersionMismatchReport',
|
||||
'readGsdSdkVersion',
|
||||
'parseGsdSdkVersion',
|
||||
'findGsdSdkOnPath',
|
||||
'isGsdSdkOnPath',
|
||||
'isLegacyGsdSdkShim',
|
||||
'trySelfLinkGsdSdk',
|
||||
'trySelfLinkGsdSdkWindows',
|
||||
'filterNpxFromPath',
|
||||
'getUserShellPath',
|
||||
'getUserShellWindowsPersistentPath',
|
||||
];
|
||||
|
||||
for (const sym of deadSymbols) {
|
||||
test(`dead symbol '${sym}' is not exported`, () => {
|
||||
assert.equal(
|
||||
typeof inst[sym],
|
||||
'undefined',
|
||||
`'${sym}' should have been removed (post #505 dead-code removal) but is still exported as ${typeof inst[sym]}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// The stale-standalone-SDK helpers (detectStaleStandaloneSdk,
|
||||
// formatStaleStandaloneSdkWarning) and the gsd-sdk shim contract surface
|
||||
// (buildWindowsShimTriple, formatSdkPathDiagnostic) that #505 kept were
|
||||
// removed when the gsd-sdk shim itself was retired (#191). Their absence is
|
||||
// covered by the dead-symbol assertions above.
|
||||
// ----------------------------------------------------------------
|
||||
});
|
||||
@@ -1,142 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Regression tests for issue #570 — three related sub-bugs in the Codex leak
|
||||
* scanner and supporting infrastructure.
|
||||
*
|
||||
* SUB-BUG A: scanForLeakedPaths recursively scans the entire targetDir,
|
||||
* including pre-existing unrelated files that contain ~/.claude references.
|
||||
* Fix: scan only files listed in gsd-file-manifest.json.
|
||||
*
|
||||
* SUB-BUG B: convertClaudeToCodexMarkdown replaces "~/.claude/" (with trailing
|
||||
* slash) but NOT bare "~/.claude" (no slash). The scanner regex
|
||||
* /(?:~|\$HOME)\/\.claude\b/ matches without trailing slash.
|
||||
* Fix: add bare word-boundary replacement.
|
||||
*
|
||||
* SUB-BUG C: writeManifest checks file.endsWith('.md') for the agents/
|
||||
* directory. Codex installs .toml agent files, so they are invisible to the
|
||||
* manifest and thus to any manifest-based scan fix.
|
||||
* Fix: also check .toml.
|
||||
*/
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const {
|
||||
install,
|
||||
convertClaudeCommandToCodexSkill,
|
||||
} = require('../bin/install.js');
|
||||
const { createTempDir, cleanup, captureConsole } = require('./helpers.cjs');
|
||||
|
||||
const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist');
|
||||
const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
||||
|
||||
function withCodexHome(codexHome, fn) {
|
||||
const prev = process.env.CODEX_HOME;
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
if (prev == null) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = prev;
|
||||
}
|
||||
}
|
||||
|
||||
describe('#570 — Codex leak scanner sub-bugs', { concurrency: false }, () => {
|
||||
let tmpRoot;
|
||||
let codexHome;
|
||||
|
||||
beforeEach(() => {
|
||||
if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) {
|
||||
execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' });
|
||||
}
|
||||
tmpRoot = createTempDir('gsd-570-');
|
||||
codexHome = path.join(tmpRoot, '.codex');
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpRoot);
|
||||
});
|
||||
|
||||
// SUB-BUG B
|
||||
test('convertClaudeToCodexMarkdown replaces bare ~/.claude (no trailing slash)', () => {
|
||||
// convertClaudeToCodexMarkdown is not exported directly; exercise it via
|
||||
// convertClaudeCommandToCodexSkill which calls it internally.
|
||||
const input = 'configDir = ~/.claude\npath = ~/.claude/hooks/\ndir = $HOME/.claude';
|
||||
const out = convertClaudeCommandToCodexSkill(input, 'gsd-test');
|
||||
|
||||
assert.ok(
|
||||
!/(?:~|\$HOME)\/\.claude\b/.test(out),
|
||||
`Expected no leaked ~/.claude reference after conversion, got:\n${out}`,
|
||||
);
|
||||
});
|
||||
|
||||
// SUB-BUG C
|
||||
test('writeManifest includes .toml agent files for Codex', () => {
|
||||
withCodexHome(codexHome, () => install(true, 'codex'));
|
||||
|
||||
const agentsDir = path.join(codexHome, 'agents');
|
||||
// Not the shared listAgentFiles() helper: this reads the INSTALLED Codex
|
||||
// dest dir and filters .toml (not source .md), so its semantics differ.
|
||||
// Confirm that Codex actually wrote .toml agent files — if none exist the
|
||||
// test is vacuous and we should fail loudly.
|
||||
const tomlFiles = fs.existsSync(agentsDir)
|
||||
? fs.readdirSync(agentsDir).filter((f) => f.startsWith('gsd-') && f.endsWith('.toml'))
|
||||
: [];
|
||||
assert.ok(
|
||||
tomlFiles.length > 0,
|
||||
`Precondition: Codex install must write at least one gsd-*.toml in agents/; found none in ${agentsDir}`,
|
||||
);
|
||||
|
||||
const manifestPath = path.join(codexHome, 'gsd-file-manifest.json');
|
||||
assert.ok(
|
||||
fs.existsSync(manifestPath),
|
||||
`gsd-file-manifest.json must exist after install; not found at ${manifestPath}`,
|
||||
);
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
const manifestKeys = Object.keys(manifest.files || {});
|
||||
|
||||
const tomlManifestKeys = manifestKeys.filter(
|
||||
(k) => k.startsWith('agents/gsd-') && k.endsWith('.toml'),
|
||||
);
|
||||
assert.ok(
|
||||
tomlManifestKeys.length > 0,
|
||||
`Expected at least one 'agents/gsd-*.toml' key in manifest.files, but found none.\n` +
|
||||
`agents/ toml files on disk: ${tomlFiles.join(', ')}\n` +
|
||||
`All manifest keys (agents/): ${manifestKeys.filter((k) => k.startsWith('agents/')).join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
// SUB-BUG A
|
||||
test('scanForLeakedPaths does not warn for pre-existing unrelated files in ~/.codex', () => {
|
||||
// Write a pre-existing file with ~/.claude references BEFORE install.
|
||||
const memoriesDir = path.join(codexHome, 'memories');
|
||||
fs.mkdirSync(memoriesDir, { recursive: true });
|
||||
const preExistingFile = path.join(memoriesDir, 'raw_memories.md');
|
||||
fs.writeFileSync(
|
||||
preExistingFile,
|
||||
'# Old memories\nI used to work in ~/.claude and $HOME/.claude regularly.\n',
|
||||
);
|
||||
|
||||
let captured;
|
||||
withCodexHome(codexHome, () => {
|
||||
captured = captureConsole(() => install(true, 'codex'));
|
||||
});
|
||||
|
||||
const combinedOutput = captured.stderr;
|
||||
|
||||
assert.ok(
|
||||
!combinedOutput.includes('memories/raw_memories.md'),
|
||||
`scanForLeakedPaths must not warn about pre-existing unrelated file memories/raw_memories.md.\n` +
|
||||
`Actual warnings:\n${combinedOutput}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,418 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Regression test for issue #704:
|
||||
* "v1.3.1 global install ships literal $gsd-core launcher paths in workflows"
|
||||
*
|
||||
* ROOT CAUSE: `convertSlashCommandsToCodexSkillMentions` had a regex
|
||||
* /(?<![a-zA-Z0-9./])\/gsd-([a-z0-9-]+)/
|
||||
* The lookbehind did NOT include `}`, so shell variable expressions like
|
||||
* `${_GSD_RUNTIME_ROOT}/gsd-core/bin/...`
|
||||
* had their `/gsd-core` matched (the char before `/` was `}`, not in the
|
||||
* exclusion set), converting it to `$gsd-core` and breaking all Codex
|
||||
* workflow launcher paths.
|
||||
*
|
||||
* FIX: Add `}` to the lookbehind set so `${VAR}/gsd-core/` is excluded.
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const {
|
||||
convertClaudeCommandToCodexSkill,
|
||||
convertSlashCommandsToCodexSkillMentions,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
// The canonical launcher snippet path that was being corrupted
|
||||
const RUNTIME_ROOT_PATH = '${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}';
|
||||
// The exact bad token reported in issue #704
|
||||
const BAD_TOKEN = '$gsd-core';
|
||||
|
||||
describe('#704 — Codex global install launcher path corruption', () => {
|
||||
test('convertClaudeCommandToCodexSkill does not corrupt ${VAR}/gsd-core/ or $(cmd)/gsd-* paths', () => {
|
||||
// Minimal fixture with the launcher snippet and command-substitution patterns
|
||||
// that were being corrupted (#704).
|
||||
const input = [
|
||||
'---',
|
||||
'description: Test skill',
|
||||
'---',
|
||||
'',
|
||||
'```bash',
|
||||
'_GSD_SHIM_NAME="gsd-tools.cjs"',
|
||||
'_GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"',
|
||||
'GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"',
|
||||
'if [ -f "$GSD_TOOLS" ]; then',
|
||||
' gsd_run() { node "$GSD_TOOLS" "$@"; }',
|
||||
'elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then',
|
||||
' GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"',
|
||||
' gsd_run() { node "$GSD_TOOLS" "$@"; }',
|
||||
'elif [ -f "$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then',
|
||||
' GSD_TOOLS="$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"',
|
||||
' gsd_run() { node "$GSD_TOOLS" "$@"; }',
|
||||
'fi',
|
||||
'# Command-substitution path form (reapply-patches pattern)',
|
||||
'candidate="$(expand_home "$KILO_CONFIG_DIR")/gsd-local-patches"',
|
||||
'```',
|
||||
].join('\n');
|
||||
|
||||
const output = convertClaudeCommandToCodexSkill(input, 'gsd-test-704');
|
||||
|
||||
// Shell-context corruption patterns from issue #704:
|
||||
// - `}$gsd-*` from shell variable expressions `${VAR}/gsd-*`
|
||||
// - `)$gsd-*` from command-substitution paths `$(cmd)/gsd-*`
|
||||
const shellCorruptionPatterns = [
|
||||
{ pattern: '}' + BAD_TOKEN, description: 'shell-variable }$gsd-core' },
|
||||
{ pattern: ')$gsd-local', description: 'command-substitution )$gsd-local-patches' },
|
||||
];
|
||||
for (const { pattern, description } of shellCorruptionPatterns) {
|
||||
assert.ok(
|
||||
!output.includes(pattern),
|
||||
`Codex skill conversion must not produce "${pattern}" (${description}). ` +
|
||||
`Offending fragment: ${
|
||||
output.includes(pattern)
|
||||
? output.substring(output.indexOf(pattern) - 50, output.indexOf(pattern) + 80)
|
||||
: '(not found)'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
// The correct path forms must be preserved — the canonical launcher path
|
||||
// (RUNTIME_ROOT_PATH) must survive Codex conversion intact.
|
||||
assert.ok(
|
||||
output.includes(RUNTIME_ROOT_PATH),
|
||||
`Expected canonical launcher path "${RUNTIME_ROOT_PATH}" to appear in the converted output. ` +
|
||||
`Got:\n${output.substring(0, 500)}`,
|
||||
);
|
||||
assert.ok(
|
||||
output.includes(')/gsd-local-patches'),
|
||||
`Expected ")/gsd-local-patches" to appear in the converted output. ` +
|
||||
`Got:\n${output.substring(0, 500)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('convertClaudeCommandToCodexSkill preserves all shell path forms (}, ) closers)', () => {
|
||||
// All these paths appear after a shell-closing character (} or )) and must
|
||||
// NOT be converted to $gsd-* by the Codex slash-command converter.
|
||||
const shellPaths = [
|
||||
// Shell variable expression forms (} closer)
|
||||
{ path: '"${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"', corruptedForm: '}$gsd-core' },
|
||||
{ path: '"${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"', corruptedForm: '}$gsd-core' },
|
||||
{ path: '"$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"', corruptedForm: '}$gsd-core' },
|
||||
// Command-substitution forms () closer) — reapply-patches pattern
|
||||
{ path: 'candidate="$(expand_home "$KILO_CONFIG_DIR")/gsd-local-patches"', corruptedForm: ')$gsd-local' },
|
||||
{ path: 'candidate="$(dirname "$(expand_home "$OPENCODE_CONFIG")")/gsd-local-patches"', corruptedForm: ')$gsd-local' },
|
||||
];
|
||||
|
||||
for (const { path: p, corruptedForm } of shellPaths) {
|
||||
const input = `---\ndescription: Test\n---\n\n\`\`\`bash\n${p}\n\`\`\``;
|
||||
const output = convertClaudeCommandToCodexSkill(input, 'gsd-test-704-paths');
|
||||
assert.ok(
|
||||
!output.includes(corruptedForm),
|
||||
`Path "${p}" was corrupted to contain "${corruptedForm}" after Codex conversion.\n` +
|
||||
`Got:\n${output}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('convertClaudeCommandToCodexSkill still converts legitimate /gsd-<cmd> slash mentions', () => {
|
||||
// Slash-command mentions (not preceded by }) should still be converted
|
||||
const input = [
|
||||
'---',
|
||||
'description: Test',
|
||||
'---',
|
||||
'',
|
||||
'Use /gsd-discuss-phase to start a discussion.',
|
||||
'Or use /gsd-plan-phase for planning.',
|
||||
'Also: /gsd:capture --backlog adds items.',
|
||||
].join('\n');
|
||||
|
||||
const output = convertClaudeCommandToCodexSkill(input, 'gsd-test-704-cmds');
|
||||
|
||||
assert.ok(
|
||||
output.includes('$gsd-discuss-phase'),
|
||||
'Expected /gsd-discuss-phase to be converted to $gsd-discuss-phase',
|
||||
);
|
||||
assert.ok(
|
||||
output.includes('$gsd-plan-phase'),
|
||||
'Expected /gsd-plan-phase to be converted to $gsd-plan-phase',
|
||||
);
|
||||
assert.ok(
|
||||
output.includes('$gsd-capture'),
|
||||
'Expected /gsd:capture to be converted to $gsd-capture',
|
||||
);
|
||||
});
|
||||
|
||||
test('actual shipped workflow files: shell-variable launcher paths contain no $gsd-core', () => {
|
||||
// Walk gsd-core/workflows/ and assert that no file produces $gsd-core
|
||||
// inside a shell variable expansion context after Codex conversion.
|
||||
//
|
||||
// NOTE: The backtick-wrapped prose-path case (`/gsd-core/workflows/update.md`)
|
||||
// was a pre-existing gap with the #704 lookbehind fix and is now addressed by
|
||||
// the positive-boundary regex introduced in #712. That case is covered by the
|
||||
// "#712" describe block below.
|
||||
//
|
||||
// We probe for the specific shell-context pattern from the issue report:
|
||||
// BAD: ${_GSD_RUNTIME_ROOT}$gsd-core/bin/
|
||||
// GOOD: ${_GSD_RUNTIME_ROOT}/gsd-core/bin/
|
||||
const workflowsDir = path.join(__dirname, '..', 'gsd-core', 'workflows');
|
||||
if (!fs.existsSync(workflowsDir)) {
|
||||
// If the directory doesn't exist, skip gracefully (non-standard layout)
|
||||
return;
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(workflowsDir)
|
||||
.filter((f) => f.endsWith('.md'))
|
||||
.map((f) => path.join(workflowsDir, f));
|
||||
|
||||
assert.ok(files.length > 0, 'Expected at least one workflow .md file');
|
||||
|
||||
// Shell-context corruption patterns from issue #704:
|
||||
// - `}$gsd-*`: closing brace from `${VAR}/gsd-*` shell variable expressions
|
||||
// - `)$gsd-*`: closing paren from `$(cmd)/gsd-*` command substitutions
|
||||
const SHELL_CORRUPTION_RE = /[})](\$gsd-[a-z])/;
|
||||
|
||||
const offending = [];
|
||||
for (const file of files) {
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
const skillName = `gsd-${path.basename(file, '.md')}`;
|
||||
const converted = convertClaudeCommandToCodexSkill(content, skillName);
|
||||
const match = converted.match(SHELL_CORRUPTION_RE);
|
||||
if (match) {
|
||||
const idx = converted.indexOf(match[0]);
|
||||
offending.push({
|
||||
file: path.relative(workflowsDir, file),
|
||||
context: converted.substring(Math.max(0, idx - 40), idx + 80),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepStrictEqual(
|
||||
offending,
|
||||
[],
|
||||
`Found shell-context path corruption ([})]$gsd-*) in Codex-converted workflow files (#704):\n` +
|
||||
offending.map((o) => ` ${o.file}: ...${o.context}...`).join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
test('commands/gsd/*.md: shell-variable launcher paths contain no $gsd-core', () => {
|
||||
// Walk commands/gsd/ and assert that no command file produces the shell-context
|
||||
// }$gsd-core corruption — since commands also go through
|
||||
// convertClaudeCommandToCodexSkill when installed globally for Codex.
|
||||
const commandsDir = path.join(__dirname, '..', 'commands', 'gsd');
|
||||
if (!fs.existsSync(commandsDir)) return;
|
||||
|
||||
const files = fs.readdirSync(commandsDir)
|
||||
.filter((f) => f.endsWith('.md'))
|
||||
.map((f) => path.join(commandsDir, f));
|
||||
|
||||
assert.ok(files.length > 0, 'Expected at least one command .md file');
|
||||
|
||||
const SHELL_CORRUPTION_RE = /[})](\$gsd-[a-z])/;
|
||||
|
||||
const offending = [];
|
||||
for (const file of files) {
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
const skillName = `gsd-${path.basename(file, '.md')}`;
|
||||
const converted = convertClaudeCommandToCodexSkill(content, skillName);
|
||||
const match = converted.match(SHELL_CORRUPTION_RE);
|
||||
if (match) {
|
||||
const idx = converted.indexOf(match[0]);
|
||||
offending.push({
|
||||
file: path.relative(commandsDir, file),
|
||||
context: converted.substring(Math.max(0, idx - 40), idx + 80),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepStrictEqual(
|
||||
offending,
|
||||
[],
|
||||
`Found shell-context path corruption ([})]$gsd-*) in Codex-converted command files (#704):\n` +
|
||||
offending.map((o) => ` ${o.file}: ...${o.context}...`).join('\n'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#712: positive-boundary slash-command conversion', () => {
|
||||
// Tests call convertSlashCommandsToCodexSkillMentions directly so the regex
|
||||
// is exercised in isolation — no frontmatter wrapping, no ADAPTER_CLOSE
|
||||
// stripping, no .claude→.codex rewrite masking the result.
|
||||
|
||||
// ── MUST-NOT-CONVERT (negative) cases ─────────────────────────────────────
|
||||
// These inputs must be returned UNCHANGED — no $gsd-* substitution.
|
||||
|
||||
test('backtick-wrapped path: `/gsd-core/workflows/update.md` is NOT converted (THE new fix)', () => {
|
||||
const input = 'See `/gsd-core/workflows/update.md` for details.';
|
||||
const result = convertSlashCommandsToCodexSkillMentions(input);
|
||||
assert.strictEqual(
|
||||
result,
|
||||
input,
|
||||
`Expected backtick-wrapped path to be unchanged. Got: ${result}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('backtick-wrapped path deeper: `/gsd-pi/bin/foo.cjs` is NOT converted', () => {
|
||||
const input = 'Run `/gsd-pi/bin/foo.cjs` directly.';
|
||||
const result = convertSlashCommandsToCodexSkillMentions(input);
|
||||
assert.strictEqual(
|
||||
result,
|
||||
input,
|
||||
`Expected deep backtick-wrapped path to be unchanged. Got: ${result}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('shell var expansion: ${_GSD_RUNTIME_ROOT}/gsd-core/bin/x is NOT converted (regression guard)', () => {
|
||||
const input = 'PATH="${_GSD_RUNTIME_ROOT}/gsd-core/bin/x"';
|
||||
const result = convertSlashCommandsToCodexSkillMentions(input);
|
||||
assert.ok(
|
||||
!result.includes('$gsd-core'),
|
||||
`Expected no $gsd-core substitution in shell var path. Got: ${result}`,
|
||||
);
|
||||
assert.ok(
|
||||
result.includes('/gsd-core/bin/x'),
|
||||
`Expected original path to be preserved. Got: ${result}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('command substitution: $(expand_home ~/.claude)/gsd-local-patches is NOT converted (regression guard)', () => {
|
||||
const input = 'candidate="$(expand_home ~/.claude)/gsd-local-patches"';
|
||||
const result = convertSlashCommandsToCodexSkillMentions(input);
|
||||
assert.ok(
|
||||
!result.includes(')$gsd-local'),
|
||||
`Expected no )$gsd-local substitution. Got: ${result}`,
|
||||
);
|
||||
assert.ok(
|
||||
result.includes(')/gsd-local-patches'),
|
||||
`Expected original path to be preserved. Got: ${result}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('plain path segment: bin/gsd-tools.cjs is NOT converted', () => {
|
||||
const input = 'node bin/gsd-tools.cjs --help';
|
||||
const result = convertSlashCommandsToCodexSkillMentions(input);
|
||||
assert.strictEqual(
|
||||
result,
|
||||
input,
|
||||
`Expected plain path segment to be unchanged. Got: ${result}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('plain path segment: .claude/gsd-core/agents — /gsd-core portion is NOT slash-command converted', () => {
|
||||
// Tests the regex in isolation: the .claude→.codex path rewrite that happens
|
||||
// inside convertClaudeToCodexMarkdown does NOT run here. We assert directly
|
||||
// that the slash-command regex leaves /gsd-core after the slash intact —
|
||||
// i.e. the `e` in `/gsd-core` is NOT treated as a command boundary.
|
||||
const input = 'Look in .claude/gsd-core/agents for the agent files.';
|
||||
const result = convertSlashCommandsToCodexSkillMentions(input);
|
||||
assert.ok(
|
||||
!result.includes('$gsd-core'),
|
||||
`Expected no $gsd-core substitution in .claude/gsd-core path. Got: ${result}`,
|
||||
);
|
||||
assert.ok(
|
||||
result.includes('/gsd-core/agents'),
|
||||
`Expected /gsd-core/agents to remain as a path segment. Got: ${result}`,
|
||||
);
|
||||
});
|
||||
|
||||
// ── MUST-CONVERT (positive) cases ─────────────────────────────────────────
|
||||
// These inputs contain legitimate /gsd-<cmd> mentions that MUST be converted.
|
||||
|
||||
test('space-preceded prose: Use /gsd-discuss-phase to start. → $gsd-discuss-phase', () => {
|
||||
const input = 'Use /gsd-discuss-phase to start.';
|
||||
const result = convertSlashCommandsToCodexSkillMentions(input);
|
||||
assert.ok(
|
||||
result.includes('$gsd-discuss-phase'),
|
||||
`Expected /gsd-discuss-phase to be converted. Got: ${result}`,
|
||||
);
|
||||
assert.ok(
|
||||
!result.includes('/gsd-discuss-phase'),
|
||||
`Expected original /gsd-discuss-phase to be replaced. Got: ${result}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('backtick-WRAPPED MENTION (single segment): Run `/gsd-execute-phase` now → `$gsd-execute-phase`', () => {
|
||||
// A backtick-wrapped COMMAND (single segment, no path continuation) MUST
|
||||
// still be converted — this guards against a naive whitespace-only fix.
|
||||
const input = 'Run `/gsd-execute-phase` now.';
|
||||
const result = convertSlashCommandsToCodexSkillMentions(input);
|
||||
assert.ok(
|
||||
result.includes('`$gsd-execute-phase`'),
|
||||
`Expected backtick-wrapped command to be converted to \`$gsd-execute-phase\`. Got: ${result}`,
|
||||
);
|
||||
assert.ok(
|
||||
!result.includes('`/gsd-execute-phase`'),
|
||||
`Expected original \`/gsd-execute-phase\` to be replaced. Got: ${result}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('parenthetical/backtick list like CONTEXT.md:59: (`/gsd-plan-phase`, `/gsd-progress`) → converted', () => {
|
||||
const input = 'Available commands: (`/gsd-plan-phase`, `/gsd-progress`) — pick one.';
|
||||
const result = convertSlashCommandsToCodexSkillMentions(input);
|
||||
assert.ok(
|
||||
result.includes('`$gsd-plan-phase`'),
|
||||
`Expected /gsd-plan-phase to be converted. Got: ${result}`,
|
||||
);
|
||||
assert.ok(
|
||||
result.includes('`$gsd-progress`'),
|
||||
`Expected /gsd-progress to be converted. Got: ${result}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('start-of-string: /gsd-manager runs → $gsd-manager runs (exercises the ^ branch of lookbehind)', () => {
|
||||
// This case is IMPOSSIBLE to test through the frontmatter-wrapping pipeline
|
||||
// (the body always has preceding chars). Direct call exercises the ^ branch.
|
||||
const input = '/gsd-manager runs the pipeline.';
|
||||
const result = convertSlashCommandsToCodexSkillMentions(input);
|
||||
assert.ok(
|
||||
result.includes('$gsd-manager'),
|
||||
`Expected /gsd-manager to be converted. Got: ${result}`,
|
||||
);
|
||||
assert.ok(
|
||||
!result.includes('/gsd-manager'),
|
||||
`Expected original /gsd-manager to be replaced. Got: ${result}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('double-quote wrapped: "/gsd-resume" → "$gsd-resume"', () => {
|
||||
const input = 'Call "/gsd-resume" to continue.';
|
||||
const result = convertSlashCommandsToCodexSkillMentions(input);
|
||||
assert.ok(
|
||||
result.includes('"$gsd-resume"'),
|
||||
`Expected "/gsd-resume" to be converted to "$gsd-resume". Got: ${result}`,
|
||||
);
|
||||
assert.ok(
|
||||
!result.includes('"/gsd-resume"'),
|
||||
`Expected original "/gsd-resume" to be replaced. Got: ${result}`,
|
||||
);
|
||||
});
|
||||
|
||||
// ── End-to-end: headline #712 bug through the real install pipeline ────────
|
||||
|
||||
test('end-to-end: backtick-wrapped path `/gsd-core/workflows/update.md` survives full Codex install pipeline', () => {
|
||||
// Uses convertClaudeCommandToCodexSkill (same pattern as #704 tests above)
|
||||
// to prove the real install path does not corrupt prose references to repo paths.
|
||||
const input = [
|
||||
'---',
|
||||
'description: Test',
|
||||
'---',
|
||||
'',
|
||||
'See `/gsd-core/workflows/update.md` for the update workflow.',
|
||||
].join('\n');
|
||||
|
||||
const output = convertClaudeCommandToCodexSkill(input, 'gsd-test-712-e2e');
|
||||
|
||||
assert.ok(
|
||||
!output.includes('$gsd-core'),
|
||||
`Expected no $gsd-core in converted output. Got:\n${output}`,
|
||||
);
|
||||
assert.ok(
|
||||
output.includes('/gsd-core/workflows/update.md'),
|
||||
`Expected backtick-wrapped path to survive conversion. Got:\n${output}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,652 +0,0 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Regression tests for bug #782 — Cline skills emission.
|
||||
*
|
||||
* gsd now emits skills to ~/.cline/skills/<name>/SKILL.md for Cline >= v3.48.
|
||||
* Skills discovery: https://docs.cline.bot/customization/skills
|
||||
*
|
||||
* (a) Converter unit test: convertClaudeCommandToClineSkill
|
||||
* (b) Integration test: installRuntimeArtifacts for cline writes SKILL.md files
|
||||
* (c) .clinerules/gsd.md still written by the install path (#787 dir form)
|
||||
* (d) Idempotency: running install twice leaves skills + .clinerules/ intact
|
||||
* (e) Full install() global: both skills AND .clinerules/gsd.md are written
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createTempDir, cleanup, captureConsole } = require('./helpers.cjs');
|
||||
|
||||
const {
|
||||
convertClaudeCommandToClineSkill,
|
||||
convertClaudeToCliineMarkdown,
|
||||
install,
|
||||
_applyRuntimeRewrites,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
const { installRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.cjs');
|
||||
|
||||
const {
|
||||
resolveRuntimeArtifactLayout,
|
||||
} = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs');
|
||||
|
||||
const {
|
||||
loadSkillsManifest,
|
||||
resolveProfile,
|
||||
} = require('../gsd-core/bin/lib/install-profiles.cjs');
|
||||
|
||||
const { nestedSkillPath } = require('./helpers/nested-layout.cjs');
|
||||
|
||||
const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
|
||||
const MANIFEST = loadSkillsManifest(REAL_COMMANDS_DIR);
|
||||
const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST });
|
||||
|
||||
// ─── (a) Converter unit test ─────────────────────────────────────────────────
|
||||
|
||||
const SAMPLE_COMMAND = `---
|
||||
name: gsd:execute-phase
|
||||
description: Execute all tasks in the current phase using Cline tools.
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Write
|
||||
- Bash
|
||||
---
|
||||
|
||||
## Objective
|
||||
|
||||
Run all tasks in the current phase.
|
||||
|
||||
See ~/.claude/skills/gsd-help/SKILL.md for reference.
|
||||
Use \`/gsd-help\` or Claude Code for details.
|
||||
`;
|
||||
|
||||
// A command that exercises all three Claude-specific frontmatter fields that
|
||||
// must NOT leak into the emitted Cline SKILL.md.
|
||||
const RICH_COMMAND = `---
|
||||
name: gsd:validate-phase
|
||||
description: Retroactively audit and fill Nyquist validation gaps for a completed phase
|
||||
argument-hint: "[phase number]"
|
||||
agent: researcher
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Write
|
||||
- Edit
|
||||
- Bash
|
||||
- Glob
|
||||
- Grep
|
||||
- Agent
|
||||
- AskUserQuestion
|
||||
---
|
||||
|
||||
## Objective
|
||||
|
||||
Audit Nyquist validation coverage. See ~/.claude/skills/gsd-help/SKILL.md for reference.
|
||||
Use Claude Code for details.
|
||||
`;
|
||||
|
||||
/**
|
||||
* Extract frontmatter block (between --- delimiters) from output.
|
||||
* Returns the raw text between the first --- and the closing ---.
|
||||
* Uses \r?\n to handle both LF and CRLF line endings (Windows parity).
|
||||
*/
|
||||
function parseFrontmatter(text) {
|
||||
const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
describe('convertClaudeCommandToClineSkill — unit', () => {
|
||||
test('emits frontmatter with name: gsd-<stem>', () => {
|
||||
const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase');
|
||||
const nameMatch = result.match(/^name:\s*(.+)$/m);
|
||||
assert.ok(nameMatch, 'frontmatter must contain name field');
|
||||
assert.ok(nameMatch[1].includes('gsd-execute-phase'), 'name must start with gsd-execute-phase');
|
||||
});
|
||||
|
||||
test('emits non-empty description in frontmatter', () => {
|
||||
const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase');
|
||||
const descMatch = result.match(/^description:\s*(.+)$/m);
|
||||
assert.ok(descMatch, 'frontmatter must contain description field');
|
||||
assert.ok(descMatch[1].trim().length > 0, 'description must not be empty');
|
||||
});
|
||||
|
||||
test('body uses .cline/ paths not .claude/', () => {
|
||||
const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase');
|
||||
// The body reference to ~/.claude/ should be rewritten to ~/.cline/
|
||||
assert.ok(!result.includes('~/.claude/skills'), 'body must not contain ~/.claude/skills');
|
||||
assert.ok(result.includes('.cline/skills'), 'body must contain .cline/skills');
|
||||
});
|
||||
|
||||
test('body replaces "Claude Code" with "Cline"', () => {
|
||||
const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase');
|
||||
assert.ok(!result.includes('Claude Code'), 'Claude Code must be replaced with Cline');
|
||||
assert.ok(result.includes('Cline'), 'result must contain Cline branding');
|
||||
});
|
||||
|
||||
test('no stray .claude/ paths in frontmatter or body', () => {
|
||||
const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase');
|
||||
// Should not contain .claude/ anywhere (except inside CLAUDE.md→.clinerules rewrites
|
||||
// but those are already handled by convertClaudeToCliineMarkdown)
|
||||
assert.ok(!result.includes('/.claude/'), 'no /.claude/ paths in output');
|
||||
});
|
||||
|
||||
// ── Fix 1 (code-review): frontmatter must be ONLY name + description ──────
|
||||
|
||||
test('frontmatter emits ONLY name and description — no allowed-tools (SAMPLE_COMMAND)', () => {
|
||||
const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase');
|
||||
const fm = parseFrontmatter(result);
|
||||
assert.ok(fm !== null, 'result must have YAML frontmatter');
|
||||
assert.ok(!fm.includes('allowed-tools'), 'frontmatter must NOT contain allowed-tools');
|
||||
assert.ok(!fm.includes('argument-hint'), 'frontmatter must NOT contain argument-hint');
|
||||
assert.ok(!fm.includes('agent:'), 'frontmatter must NOT contain agent:');
|
||||
});
|
||||
|
||||
test('frontmatter emits ONLY name and description — no allowed-tools/argument-hint/agent (RICH_COMMAND)', () => {
|
||||
const result = convertClaudeCommandToClineSkill(RICH_COMMAND, 'gsd-validate-phase');
|
||||
const fm = parseFrontmatter(result);
|
||||
assert.ok(fm !== null, 'result must have YAML frontmatter');
|
||||
assert.ok(!fm.includes('allowed-tools'), 'frontmatter must NOT contain allowed-tools');
|
||||
assert.ok(!fm.includes('argument-hint'), 'frontmatter must NOT contain argument-hint');
|
||||
assert.ok(!fm.includes('agent:'), 'frontmatter must NOT contain agent:');
|
||||
});
|
||||
|
||||
test('name == gsd-validate-phase for RICH_COMMAND', () => {
|
||||
const result = convertClaudeCommandToClineSkill(RICH_COMMAND, 'gsd-validate-phase');
|
||||
const nameMatch = result.match(/^name:\s*(.+)$/m);
|
||||
assert.ok(nameMatch, 'must have name field');
|
||||
// yamlIdentifier may quote the value; strip surrounding quotes for comparison
|
||||
const nameVal = nameMatch[1].replace(/^['"]|['"]$/g, '').trim();
|
||||
assert.strictEqual(nameVal, 'gsd-validate-phase', `name must be gsd-validate-phase, got: ${nameVal}`);
|
||||
});
|
||||
|
||||
test('description is non-empty and <= 1024 chars for RICH_COMMAND', () => {
|
||||
const result = convertClaudeCommandToClineSkill(RICH_COMMAND, 'gsd-validate-phase');
|
||||
const descMatch = result.match(/^description:\s*(.+)$/m);
|
||||
assert.ok(descMatch, 'must have description field');
|
||||
const desc = descMatch[1].replace(/^['"]|['"]$/g, '').trim();
|
||||
assert.ok(desc.length > 0, 'description must be non-empty');
|
||||
assert.ok(desc.length <= 1024, `description must be <= 1024 chars, got ${desc.length}`);
|
||||
});
|
||||
|
||||
test('description truncated to <=1024 chars when source description is very long', () => {
|
||||
const longDesc = 'A'.repeat(2000);
|
||||
const longDescCommand = `---\nname: gsd:test\ndescription: ${longDesc}\n---\n\nBody text.\n`;
|
||||
const result = convertClaudeCommandToClineSkill(longDescCommand, 'gsd-test');
|
||||
const descMatch = result.match(/^description:\s*'?(.*?)'?$/m);
|
||||
assert.ok(descMatch, 'must have description field');
|
||||
// The raw description value (unquoted) should be <=1024 chars
|
||||
// The result string after the --- block will have the quoted form; check raw length
|
||||
// by checking the whole result doesn't have the full 2000-char string
|
||||
assert.ok(!result.includes('A'.repeat(1025)), 'description must be truncated to 1024 chars');
|
||||
});
|
||||
|
||||
test('returns content unchanged when source has no frontmatter', () => {
|
||||
const noFm = 'Just a body, no frontmatter here.\n';
|
||||
const result = convertClaudeCommandToClineSkill(noFm, 'gsd-test');
|
||||
assert.strictEqual(result, noFm, 'content without frontmatter must be returned unchanged');
|
||||
});
|
||||
|
||||
test('RICH_COMMAND body uses .cline/ paths and Cline branding', () => {
|
||||
const result = convertClaudeCommandToClineSkill(RICH_COMMAND, 'gsd-validate-phase');
|
||||
assert.ok(!result.includes('~/.claude/'), 'body must not contain ~/.claude/');
|
||||
assert.ok(result.includes('.cline/'), 'body must contain .cline/ paths');
|
||||
assert.ok(!result.includes('Claude Code'), 'body must not contain "Claude Code"');
|
||||
assert.ok(result.includes('Cline'), 'body must reference Cline');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── (b) + (c) + (d) Integration tests ────────────────────────────────────────
|
||||
|
||||
describe('installRuntimeArtifacts — cline skills emission', () => {
|
||||
test('cline global: writes gsd-prefixed skill dirs under skills/', (t) => {
|
||||
const configDir = createTempDir('gsd-cline-skills-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE);
|
||||
|
||||
const layout = resolveRuntimeArtifactLayout('cline', configDir, 'global');
|
||||
const skillsKind = layout.kinds.find(k => k.kind === 'skills');
|
||||
assert.ok(skillsKind, 'cline must have a skills kind after #782');
|
||||
|
||||
const skillsDir = path.join(configDir, skillsKind.destSubpath);
|
||||
assert.ok(fs.existsSync(skillsDir), 'skills/ directory must be created');
|
||||
|
||||
const helpSkillDir = path.join(skillsDir, `${skillsKind.prefix}help`);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(helpSkillDir, 'SKILL.md')),
|
||||
`gsd-help/SKILL.md must exist under ${skillsKind.destSubpath}/`
|
||||
);
|
||||
});
|
||||
|
||||
test('cline global: SKILL.md has valid cline frontmatter (name + description)', (t) => {
|
||||
const configDir = createTempDir('gsd-cline-fm-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE);
|
||||
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
const helpSkill = path.join(skillsDir, 'gsd-help', 'SKILL.md');
|
||||
assert.ok(fs.existsSync(helpSkill), 'gsd-help/SKILL.md must exist');
|
||||
|
||||
const content = fs.readFileSync(helpSkill, 'utf8');
|
||||
// Must have YAML frontmatter
|
||||
assert.ok(content.startsWith('---'), 'SKILL.md must start with YAML frontmatter');
|
||||
assert.ok(content.includes('name:'), 'frontmatter must have name field');
|
||||
assert.ok(content.includes('description:'), 'frontmatter must have description field');
|
||||
// name must be gsd-help
|
||||
const nameMatch = content.match(/^name:\s*(.+)$/m);
|
||||
assert.ok(nameMatch, 'must have name field');
|
||||
assert.ok(nameMatch[1].includes('gsd-help'), `name must include gsd-help, got: ${nameMatch[1]}`);
|
||||
});
|
||||
|
||||
test('cline global: SKILL.md uses .cline/ paths not .claude/', (t) => {
|
||||
const configDir = createTempDir('gsd-cline-paths-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE);
|
||||
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
// Check all installed skill files for stray .claude/ references
|
||||
const skills = fs.readdirSync(skillsDir).filter(n => n.startsWith('gsd-'));
|
||||
assert.ok(skills.length > 0, 'at least one gsd- skill must be installed');
|
||||
|
||||
for (const skillName of skills) {
|
||||
const skillFile = path.join(skillsDir, skillName, 'SKILL.md');
|
||||
if (!fs.existsSync(skillFile)) continue;
|
||||
const content = fs.readFileSync(skillFile, 'utf8');
|
||||
assert.ok(
|
||||
!content.includes('~/.claude/'),
|
||||
`${skillName}/SKILL.md must not contain ~/.claude/ — found stray path`
|
||||
);
|
||||
assert.ok(
|
||||
!content.includes('/.claude/'),
|
||||
`${skillName}/SKILL.md must not contain /.claude/ — found stray path`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('cline global: skill count matches resolved profile', (t) => {
|
||||
const configDir = createTempDir('gsd-cline-count-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE);
|
||||
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
const count = fs.readdirSync(skillsDir)
|
||||
.filter(n => n.startsWith('gsd-') && fs.statSync(path.join(skillsDir, n)).isDirectory())
|
||||
.length;
|
||||
|
||||
if (RESOLVED_CORE.skills !== '*') {
|
||||
assert.strictEqual(count, RESOLVED_CORE.skills.size,
|
||||
`installed skill count (${count}) must match profile size (${RESOLVED_CORE.skills.size})`);
|
||||
} else {
|
||||
assert.ok(count > 0, 'must install at least 1 skill');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('installRuntimeArtifacts — cline idempotency', () => {
|
||||
test('cline: running install twice leaves skills intact (idempotency)', (t) => {
|
||||
const configDir = createTempDir('gsd-cline-idempotent-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
// First install
|
||||
installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE);
|
||||
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
const countAfterFirst = fs.readdirSync(skillsDir)
|
||||
.filter(n => n.startsWith('gsd-') && fs.statSync(path.join(skillsDir, n)).isDirectory())
|
||||
.length;
|
||||
|
||||
// Second install (upgrade over existing)
|
||||
installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE);
|
||||
|
||||
const countAfterSecond = fs.readdirSync(skillsDir)
|
||||
.filter(n => n.startsWith('gsd-') && fs.statSync(path.join(skillsDir, n)).isDirectory())
|
||||
.length;
|
||||
|
||||
assert.strictEqual(countAfterFirst, countAfterSecond,
|
||||
`skill count must be stable across installs: first=${countAfterFirst} second=${countAfterSecond}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── (e) Full install() global — coexistence regression ───────────────────────
|
||||
//
|
||||
// Issue #782 explicitly requires that a global Cline install writes BOTH:
|
||||
// - skills/<gsd-*>/SKILL.md (skills for Cline >= v3.48)
|
||||
// - .clinerules/gsd.md (rules dir form introduced by #787)
|
||||
//
|
||||
// installRuntimeArtifacts() tests cover skills in isolation; this test exercises
|
||||
// the FULL install() code path to ensure neither artifact is silently dropped.
|
||||
|
||||
describe('install() global cline — coexistence: skills AND .clinerules', () => {
|
||||
let tmpGlobalDir;
|
||||
let originalClineConfigDir;
|
||||
|
||||
beforeEach(() => {
|
||||
originalClineConfigDir = process.env.CLINE_CONFIG_DIR;
|
||||
tmpGlobalDir = createTempDir('gsd-cline-global-');
|
||||
// Redirect CLINE_CONFIG_DIR to the temp dir so install() never touches ~/.cline
|
||||
process.env.CLINE_CONFIG_DIR = tmpGlobalDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalClineConfigDir !== undefined) {
|
||||
process.env.CLINE_CONFIG_DIR = originalClineConfigDir;
|
||||
} else {
|
||||
delete process.env.CLINE_CONFIG_DIR;
|
||||
}
|
||||
cleanup(tmpGlobalDir);
|
||||
});
|
||||
|
||||
test('global cline install writes at least one gsd-* SKILL.md under skills/', () => {
|
||||
captureConsole(() => install(true, 'cline'));
|
||||
|
||||
const skillsDir = path.join(tmpGlobalDir, 'skills');
|
||||
assert.ok(
|
||||
fs.existsSync(skillsDir),
|
||||
`skills/ directory must exist under ${tmpGlobalDir} after global cline install`
|
||||
);
|
||||
|
||||
// full profile: gsd-help is nested under gsd-ns-manage/skills/help/SKILL.md
|
||||
const helpSkillFile = nestedSkillPath(skillsDir, 'gsd-', 'help');
|
||||
assert.ok(
|
||||
fs.existsSync(helpSkillFile),
|
||||
`${path.relative(tmpGlobalDir, helpSkillFile)} must exist under ${tmpGlobalDir} — skills emission broken for global cline`
|
||||
);
|
||||
});
|
||||
|
||||
test('global cline install writes .clinerules/gsd.md to the global config dir', () => {
|
||||
captureConsole(() => install(true, 'cline'));
|
||||
|
||||
// For a global Cline install, targetDir = getGlobalDir('cline') = CLINE_CONFIG_DIR.
|
||||
// The cline-rules surface (#787) writes the .clinerules/ DIRECTORY form:
|
||||
// .clinerules/gsd.md (rule file)
|
||||
// .clinerules/hooks/PreToolUse (lifecycle hook)
|
||||
const clinerulesMd = path.join(tmpGlobalDir, '.clinerules', 'gsd.md');
|
||||
assert.ok(
|
||||
fs.existsSync(clinerulesMd),
|
||||
`.clinerules/gsd.md must exist at ${clinerulesMd} — coexistence with skills broken for global cline (#782+#787)`
|
||||
);
|
||||
});
|
||||
|
||||
test('global cline .clinerules/gsd.md contains GSD instructions', () => {
|
||||
captureConsole(() => install(true, 'cline'));
|
||||
|
||||
// #787 dir form: rule content lives in .clinerules/gsd.md, not a flat .clinerules file
|
||||
const clinerulesMd = path.join(tmpGlobalDir, '.clinerules', 'gsd.md');
|
||||
assert.ok(fs.existsSync(clinerulesMd), '.clinerules/gsd.md must exist');
|
||||
const content = fs.readFileSync(clinerulesMd, 'utf8');
|
||||
assert.ok(
|
||||
content.includes('GSD') || content.includes('gsd'),
|
||||
'.clinerules/gsd.md must reference GSD'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Fix 3 regression: converter rewrites bare ~/.claude and CLAUDE_CONFIG_DIR ──
|
||||
//
|
||||
// convertClaudeToCliineMarkdown must also handle bare ~/.claude (no trailing
|
||||
// slash) and the CLAUDE_CONFIG_DIR env-var name. surface.md contains these;
|
||||
// the emitted Cline SKILL.md must contain no such stale Claude refs.
|
||||
|
||||
describe('convertClaudeToCliineMarkdown — bare ~/.claude and CLAUDE_CONFIG_DIR (Fix 3)', () => {
|
||||
const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md');
|
||||
|
||||
test('no bare ~/.claude in converted surface.md', () => {
|
||||
const raw = fs.readFileSync(surfacePath, 'utf8');
|
||||
const result = convertClaudeToCliineMarkdown(raw);
|
||||
// ~/.claude followed by a word-boundary (not a /) must be gone
|
||||
assert.ok(
|
||||
!/~\/\.claude\b/.test(result),
|
||||
'converted surface.md must not contain bare ~/.claude'
|
||||
);
|
||||
});
|
||||
|
||||
test('no CLAUDE_CONFIG_DIR in converted surface.md', () => {
|
||||
const raw = fs.readFileSync(surfacePath, 'utf8');
|
||||
const result = convertClaudeToCliineMarkdown(raw);
|
||||
assert.ok(
|
||||
!result.includes('CLAUDE_CONFIG_DIR'),
|
||||
'converted surface.md must not contain CLAUDE_CONFIG_DIR'
|
||||
);
|
||||
});
|
||||
|
||||
test('CLAUDE_CONFIG_DIR rewritten to CLINE_CONFIG_DIR', () => {
|
||||
const input = 'Use CLAUDE_CONFIG_DIR or $HOME/.claude to configure';
|
||||
const result = convertClaudeToCliineMarkdown(input);
|
||||
assert.ok(result.includes('CLINE_CONFIG_DIR'), 'CLAUDE_CONFIG_DIR must become CLINE_CONFIG_DIR');
|
||||
assert.ok(!result.includes('CLAUDE_CONFIG_DIR'), 'CLAUDE_CONFIG_DIR must be gone');
|
||||
});
|
||||
|
||||
test('bare ~/.claude rewritten to ~/.cline', () => {
|
||||
const input = 'Config dir: (~/.claude), skills at ~/.claude/skills';
|
||||
const result = convertClaudeToCliineMarkdown(input);
|
||||
assert.ok(!result.includes('~/.claude'), 'bare ~/.claude must be rewritten');
|
||||
assert.ok(result.includes('~/.cline'), 'must rewrite to ~/.cline');
|
||||
});
|
||||
|
||||
test('installRuntimeArtifacts cline global: gsd-surface SKILL.md has no bare ~/.claude or CLAUDE_CONFIG_DIR', (t) => {
|
||||
const configDir = createTempDir('gsd-cline-surface-fix3-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
const MANIFEST_FULL = require('../gsd-core/bin/lib/install-profiles.cjs').loadSkillsManifest(
|
||||
path.join(__dirname, '..', 'commands', 'gsd')
|
||||
);
|
||||
const RESOLVED_FULL = require('../gsd-core/bin/lib/install-profiles.cjs').resolveProfile({
|
||||
modes: ['full'], manifest: MANIFEST_FULL,
|
||||
});
|
||||
|
||||
installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_FULL);
|
||||
|
||||
// full profile: surface is nested under gsd-ns-manage/skills/surface/SKILL.md
|
||||
const surfaceSkill = nestedSkillPath(path.join(configDir, 'skills'), 'gsd-', 'surface');
|
||||
assert.ok(fs.existsSync(surfaceSkill), `${path.relative(configDir, surfaceSkill)} must exist for full profile`);
|
||||
|
||||
const content = fs.readFileSync(surfaceSkill, 'utf8');
|
||||
assert.ok(
|
||||
!/~\/\.claude\b/.test(content),
|
||||
'gsd-surface SKILL.md must not contain bare ~/.claude (Fix 3)'
|
||||
);
|
||||
assert.ok(
|
||||
!content.includes('CLAUDE_CONFIG_DIR'),
|
||||
'gsd-surface SKILL.md must not contain CLAUDE_CONFIG_DIR (Fix 3)'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Fix 1 regression: custom CLINE_CONFIG_DIR → embedded paths use custom dir ──
|
||||
//
|
||||
// _applyRuntimeRewrites for cline must rewrite ~/.cline/ → pathPrefix.
|
||||
// For default global installs, pathPrefix = "$HOME/.cline/" (unchanged).
|
||||
// For custom installs (CLINE_CONFIG_DIR=/custom), pathPrefix = "/custom/" and
|
||||
// all embedded ~/.cline/ refs in SKILL.md must become /custom/...
|
||||
|
||||
describe('_applyRuntimeRewrites — cline custom-dir embedded path (Fix 1)', () => {
|
||||
test('default pathPrefix ($HOME/.cline/) leaves ~/.cline refs as $HOME/.cline', () => {
|
||||
const content = 'See ~/.cline/skills/gsd-help/SKILL.md for reference.\nBare: ~/.cline\n';
|
||||
const result = _applyRuntimeRewrites(content, 'cline', '$HOME/.cline/');
|
||||
assert.ok(result.includes('$HOME/.cline/'), 'default prefix must map ~/.cline/ to $HOME/.cline/');
|
||||
assert.ok(!result.includes('~/.cline'), 'no tilde form should remain after rewrite');
|
||||
});
|
||||
|
||||
test('custom pathPrefix rewrites ~/.cline/ → custom path in SKILL.md body', () => {
|
||||
const content = 'See ~/.cline/skills/gsd-help/SKILL.md for reference.\nBare: ~/.cline\n';
|
||||
const result = _applyRuntimeRewrites(content, 'cline', '/custom/cline-dir/');
|
||||
assert.ok(result.includes('/custom/cline-dir/'), 'custom prefix must appear in output');
|
||||
assert.ok(!result.includes('~/.cline'), 'no tilde cline form should remain after custom rewrite');
|
||||
});
|
||||
|
||||
test('custom pathPrefix rewrites residual ~/.claude/ safety net', () => {
|
||||
const content = 'Residual: ~/.claude/skills\n';
|
||||
const result = _applyRuntimeRewrites(content, 'cline', '/custom/cline-dir/');
|
||||
assert.ok(result.includes('/custom/cline-dir/'), 'safety-net ~/.claude/ also rewritten to custom prefix');
|
||||
assert.ok(!result.includes('~/.claude/'), 'no ~/.claude/ should remain');
|
||||
});
|
||||
|
||||
test('installRuntimeArtifacts cline with CLINE_CONFIG_DIR custom: SKILL.md embeds custom path', (t) => {
|
||||
const configDir = createTempDir('gsd-cline-custom-dir-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
const MANIFEST_FULL = require('../gsd-core/bin/lib/install-profiles.cjs').loadSkillsManifest(
|
||||
path.join(__dirname, '..', 'commands', 'gsd')
|
||||
);
|
||||
const RESOLVED_FULL = require('../gsd-core/bin/lib/install-profiles.cjs').resolveProfile({
|
||||
modes: ['full'], manifest: MANIFEST_FULL,
|
||||
});
|
||||
|
||||
installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_FULL);
|
||||
|
||||
// gsd-surface SKILL.md references config paths; with a custom configDir
|
||||
// (not under $HOME), pathPrefix will be the absolute custom path.
|
||||
// full profile: surface is nested under gsd-ns-manage/skills/surface/SKILL.md
|
||||
const surfaceSkill = nestedSkillPath(path.join(configDir, 'skills'), 'gsd-', 'surface');
|
||||
assert.ok(fs.existsSync(surfaceSkill), `${path.relative(configDir, surfaceSkill)} must exist`);
|
||||
|
||||
const content = fs.readFileSync(surfaceSkill, 'utf8');
|
||||
// With a custom dir (path under /tmp, not ~/.cline), the output must NOT
|
||||
// contain ~/.cline/ or $HOME/.cline/ — it must embed the actual configDir path.
|
||||
assert.ok(
|
||||
!content.includes('~/.cline/'),
|
||||
`gsd-surface SKILL.md must not contain ~/.cline/ when configDir=${configDir} (Fix 1)`
|
||||
);
|
||||
// The custom path must appear somewhere in the file
|
||||
// (configDir is a /tmp/... path so pathPrefix = configDir+'/').
|
||||
// Production normalizes backslashes to forward slashes via
|
||||
// path.resolve(configDir).replace(/\\/g, '/'), so compare against that
|
||||
// form — otherwise this assertion fails on Windows where mkdtempSync
|
||||
// returns a backslash path (e.g. C:\Users\...) but the emitted content
|
||||
// already has forward slashes (C:/Users/...).
|
||||
const expectedPath = path.resolve(configDir).replace(/\\/g, '/');
|
||||
assert.ok(
|
||||
content.includes(expectedPath),
|
||||
`gsd-surface SKILL.md must embed custom configDir path ${expectedPath} (Fix 1)`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Fix 4 regression: description truncation is code-point-aware ────────────
|
||||
//
|
||||
// Naive UTF-16 slicing (`str.slice(0, 1021)`) can split a surrogate pair when
|
||||
// the cut falls between the high and low surrogate of a multibyte character
|
||||
// (e.g. emoji U+1F600, which is encoded as two UTF-16 code units). The fix
|
||||
// uses Array.from() to split by code point, guaranteeing that the truncated
|
||||
// value never contains a lone surrogate.
|
||||
|
||||
describe('convertClaudeCommandToClineSkill — code-point-aware truncation (Fix 4)', () => {
|
||||
/**
|
||||
* Build a frontmatter+body command string whose description is:
|
||||
* - exactly `prefixLen` ASCII chars
|
||||
* - followed by `emojiCount` repetitions of '😀' (U+1F600, 2 UTF-16 units)
|
||||
* - total UTF-16 length is prefixLen + emojiCount * 2
|
||||
*/
|
||||
function makeEmojiCommand(prefixLen, emojiCount) {
|
||||
const desc = 'A'.repeat(prefixLen) + '😀'.repeat(emojiCount);
|
||||
return `---\nname: gsd:emoji-test\ndescription: ${desc}\n---\n\nBody.\n`;
|
||||
}
|
||||
|
||||
test('emitted description is <= 1024 code points when source overflows', () => {
|
||||
// 1020 ASCII chars + 4 emoji = 1020 + 8 UTF-16 units = 1028 UTF-16 units > 1024.
|
||||
// Code-point count = 1020 + 4 = 1024 — exactly at the boundary BEFORE adding '...'.
|
||||
// After truncation to 1021 code points + '...' → 1024 code points total.
|
||||
const cmd = makeEmojiCommand(1020, 10); // 1030 code points → must truncate
|
||||
const result = convertClaudeCommandToClineSkill(cmd, 'gsd-emoji-test');
|
||||
|
||||
// Extract raw description value (strip surrounding YAML quotes if present)
|
||||
const descMatch = result.match(/^description:\s*(.+)$/m);
|
||||
assert.ok(descMatch, 'emitted SKILL.md must have a description field');
|
||||
const rawDesc = descMatch[1].trim().replace(/^['"]|['"]$/g, '');
|
||||
|
||||
const codePoints = Array.from(rawDesc);
|
||||
assert.ok(
|
||||
codePoints.length <= 1024,
|
||||
`emitted description must be <= 1024 code points, got ${codePoints.length}`
|
||||
);
|
||||
});
|
||||
|
||||
test('emitted description ends with "..." when truncated', () => {
|
||||
const cmd = makeEmojiCommand(1020, 10); // 1030 code points → must truncate
|
||||
const result = convertClaudeCommandToClineSkill(cmd, 'gsd-emoji-test');
|
||||
|
||||
const descMatch = result.match(/^description:\s*(.+)$/m);
|
||||
assert.ok(descMatch, 'emitted SKILL.md must have a description field');
|
||||
const rawDesc = descMatch[1].trim().replace(/^['"]|['"]$/g, '');
|
||||
|
||||
assert.ok(rawDesc.endsWith('...'), `truncated description must end with "...", got: ${rawDesc.slice(-10)}`);
|
||||
});
|
||||
|
||||
test('emitted description has no lone surrogate (no split emoji)', () => {
|
||||
// Place emojis exactly at positions 1021–1025 (code points) so that a naive
|
||||
// UTF-16 slice at 1021 code units would cut inside the second emoji's surrogate pair.
|
||||
// 1019 ASCII chars + 6 emoji = 1025 code points (>1024, triggers truncation).
|
||||
// UTF-16 length = 1019 + 12 = 1031. Naive slice(0,1021) yields 1019 ASCII +
|
||||
// the HIGH surrogate of emoji[0] — a lone surrogate.
|
||||
const cmd = makeEmojiCommand(1019, 6);
|
||||
const result = convertClaudeCommandToClineSkill(cmd, 'gsd-emoji-test');
|
||||
|
||||
const descMatch = result.match(/^description:\s*(.+)$/m);
|
||||
assert.ok(descMatch, 'emitted SKILL.md must have a description field');
|
||||
const rawDesc = descMatch[1].trim().replace(/^['"]|['"]$/g, '');
|
||||
|
||||
// Verify no lone surrogate: every char's code point must be outside [0xD800, 0xDFFF].
|
||||
const hasLoneSurrogate = [...rawDesc].some(c => {
|
||||
const cp = c.codePointAt(0);
|
||||
return cp >= 0xD800 && cp <= 0xDFFF;
|
||||
});
|
||||
assert.ok(!hasLoneSurrogate, 'emitted description must not contain a lone surrogate');
|
||||
|
||||
// Also round-trip through Buffer to confirm the string is valid UTF-8 encodable.
|
||||
assert.doesNotThrow(
|
||||
() => Buffer.from(rawDesc, 'utf8').toString('utf8'),
|
||||
'emitted description must round-trip through Buffer without error'
|
||||
);
|
||||
});
|
||||
|
||||
test('short description (<= 1024 code points) is not truncated', () => {
|
||||
// 10 ASCII + 5 emoji = 15 code points — well under the limit.
|
||||
const cmd = makeEmojiCommand(10, 5);
|
||||
const result = convertClaudeCommandToClineSkill(cmd, 'gsd-emoji-test');
|
||||
|
||||
const descMatch = result.match(/^description:\s*(.+)$/m);
|
||||
assert.ok(descMatch, 'emitted SKILL.md must have a description field');
|
||||
const rawDesc = descMatch[1].trim().replace(/^['"]|['"]$/g, '');
|
||||
|
||||
assert.ok(!rawDesc.endsWith('...'), 'short description must NOT be truncated with "..."');
|
||||
// Must contain the original emoji characters intact
|
||||
assert.ok(rawDesc.includes('😀'), 'short description must preserve emoji characters');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Fix 2 regression: cline local scope emits no skills ─────────────────────
|
||||
//
|
||||
// resolveRuntimeArtifactLayout('cline', dir, 'local') must return 0 kinds.
|
||||
// installRuntimeArtifacts('cline', dir, 'local') must not write any skills.
|
||||
|
||||
describe('resolveRuntimeArtifactLayout — cline scope-aware (Fix 2)', () => {
|
||||
test('cline local: kinds.length === 0 (no skills for local scope)', () => {
|
||||
const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs');
|
||||
const layout = resolveRuntimeArtifactLayout('cline', '/tmp/x', 'local');
|
||||
assert.strictEqual(layout.kinds.length, 0, 'cline local must have 0 kinds');
|
||||
});
|
||||
|
||||
test('cline global: kinds.length === 1 (skills kind)', () => {
|
||||
const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs');
|
||||
const layout = resolveRuntimeArtifactLayout('cline', '/tmp/x', 'global');
|
||||
assert.strictEqual(layout.kinds.length, 1, 'cline global must have 1 skills kind');
|
||||
assert.strictEqual(layout.kinds[0].kind, 'skills');
|
||||
});
|
||||
|
||||
test('installRuntimeArtifacts cline local: no skills/ dir created', (t) => {
|
||||
const configDir = createTempDir('gsd-cline-local-noskills-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
assert.doesNotThrow(() => installRuntimeArtifacts('cline', configDir, 'local', RESOLVED_CORE));
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
assert.ok(
|
||||
!fs.existsSync(skillsDir),
|
||||
`skills/ must NOT be created for cline local install (Fix 2), but found ${skillsDir}`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,215 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Tests assert on text in bin/install.js (Codex adapter header prose) —
|
||||
// the adapter text IS the product loaded by Codex agents at runtime.
|
||||
|
||||
'use strict';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const INSTALL_JS = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
const src = fs.readFileSync(INSTALL_JS, 'utf8');
|
||||
|
||||
// Helper: extract Section C from the raw source text.
|
||||
// Anchors on the heading and ends at </codex_skill_adapter>.
|
||||
function getSectionC() {
|
||||
const headingIdx = src.indexOf('## C. Task() → spawn_agent Mapping');
|
||||
assert.ok(headingIdx >= 0, 'Section C heading must exist in bin/install.js');
|
||||
const closeTag = src.indexOf('</codex_skill_adapter>', headingIdx);
|
||||
assert.ok(closeTag >= 0, 'Section C must be followed by </codex_skill_adapter>');
|
||||
return src.slice(headingIdx, closeTag);
|
||||
}
|
||||
|
||||
describe('bug #851: Codex adapter documents multi_agent_v1 schema limitation and fallback', () => {
|
||||
|
||||
// (a) Schema-detection step: the adapter must require the agent to inspect
|
||||
// spawn_agent's parameter schema BEFORE deciding how to dispatch.
|
||||
test('(a) schema-detection: adapter requires inspecting spawn_agent schema before dispatching', () => {
|
||||
const sectionC = getSectionC();
|
||||
|
||||
// Must name BOTH schema variants so the agent knows what to look for
|
||||
assert.ok(
|
||||
sectionC.includes('multi_agent_v1'),
|
||||
'Section C must name the multi_agent_v1 schema to identify the limited form',
|
||||
);
|
||||
assert.ok(
|
||||
sectionC.includes('multi_agent_v2') || sectionC.includes('agent_type-capable'),
|
||||
'Section C must name the typed schema (multi_agent_v2 or agent_type-capable) as the capable form',
|
||||
);
|
||||
|
||||
// Must instruct schema inspection before spawning
|
||||
assert.ok(
|
||||
sectionC.includes('tool_search') || sectionC.includes('inspect') || sectionC.includes('schema'),
|
||||
'Section C must instruct the agent to inspect the spawn_agent schema (via tool_search or similar)',
|
||||
);
|
||||
|
||||
// All three requirements together (AND):
|
||||
assert.ok(
|
||||
sectionC.includes('multi_agent_v1') &&
|
||||
(sectionC.includes('multi_agent_v2') || sectionC.includes('agent_type-capable')) &&
|
||||
(sectionC.includes('tool_search') || sectionC.includes('inspect') || sectionC.includes('schema')),
|
||||
'Section C must require schema-detection: name both schema variants AND instruct inspection before spawning',
|
||||
);
|
||||
});
|
||||
|
||||
// (b) Active-config-root resolution: the TOML path must describe how to
|
||||
// resolve the config root (honoring $CODEX_HOME / --config-dir / --local),
|
||||
// not imply a single fixed path.
|
||||
test('(b) active-config-root: fallback TOML path resolves the active Codex config root', () => {
|
||||
const sectionC = getSectionC();
|
||||
|
||||
// Must mention the agents/<agent-name>.toml relative path
|
||||
assert.ok(
|
||||
sectionC.includes('agents/<agent-name>.toml'),
|
||||
'Section C must reference agents/<agent-name>.toml for the TOML extraction step',
|
||||
);
|
||||
|
||||
// Must describe dynamic config-root resolution (at least two of the three
|
||||
// override mechanisms, plus the word "config" to anchor context)
|
||||
const mentionsCodexHome = sectionC.includes('$CODEX_HOME') || sectionC.includes('CODEX_HOME');
|
||||
const mentionsConfigDir = sectionC.includes('--config-dir') || sectionC.includes('config-dir');
|
||||
const mentionsLocal = sectionC.includes('--local') || sectionC.includes('.codex') || sectionC.includes('local');
|
||||
const mentionsConfigRoot = sectionC.includes('config root') || sectionC.includes('config.toml') || sectionC.includes('config directory');
|
||||
|
||||
assert.ok(
|
||||
mentionsCodexHome,
|
||||
'Section C fallback must mention $CODEX_HOME for config-root resolution',
|
||||
);
|
||||
assert.ok(
|
||||
mentionsConfigDir,
|
||||
'Section C fallback must mention --config-dir for config-root resolution',
|
||||
);
|
||||
assert.ok(
|
||||
mentionsLocal,
|
||||
'Section C fallback must mention --local / .codex for config-root resolution',
|
||||
);
|
||||
assert.ok(
|
||||
mentionsConfigRoot,
|
||||
'Section C fallback must describe the concept of an active config root (config.toml or config root/directory)',
|
||||
);
|
||||
|
||||
// AND: all four required elements together
|
||||
assert.ok(
|
||||
mentionsCodexHome && mentionsConfigDir && mentionsLocal && mentionsConfigRoot,
|
||||
'Section C fallback must describe active-config-root resolution: $CODEX_HOME + --config-dir + --local + config-root concept (AND logic)',
|
||||
);
|
||||
|
||||
// Must NOT contain the literal ~/.codex/ (would be rewritten by _applyRuntimeRewrites
|
||||
// and cause bug-3582 to diverge)
|
||||
assert.ok(
|
||||
!sectionC.includes('~/.codex/'),
|
||||
'Section C must NOT contain the literal ~/.codex/ substring (breaks bug-3582 materialization test)',
|
||||
);
|
||||
});
|
||||
|
||||
// (c) "NOT equivalent" label: the workaround must be explicitly labeled as
|
||||
// not equivalent to typed gsd-planner/gsd-executor execution.
|
||||
test('(c) not-equivalent label: generic-agent workaround is labeled as NOT equivalent to typed dispatch', () => {
|
||||
const sectionC = getSectionC();
|
||||
|
||||
// Must name at least one typed agent
|
||||
const namesTypedAgent =
|
||||
sectionC.includes('gsd-planner') ||
|
||||
sectionC.includes('gsd-executor') ||
|
||||
sectionC.includes('typed GSD agent') ||
|
||||
sectionC.includes('typed gsd-');
|
||||
|
||||
// Must contain explicit "not equivalent" / "NOT equivalent" / negation language
|
||||
const hasNotEquivalent =
|
||||
sectionC.toLowerCase().includes('not equivalent') ||
|
||||
sectionC.includes('NOT equivalent') ||
|
||||
sectionC.includes('is NOT possible');
|
||||
|
||||
// Must name the workaround as a workaround, not a first-class path
|
||||
const hasWorkaroundLabel =
|
||||
sectionC.includes('workaround') ||
|
||||
sectionC.includes('fallback');
|
||||
|
||||
assert.ok(
|
||||
namesTypedAgent,
|
||||
'Section C must name at least one typed GSD agent (gsd-planner, gsd-executor, or "typed GSD agent")',
|
||||
);
|
||||
assert.ok(
|
||||
hasNotEquivalent,
|
||||
'Section C must contain explicit "not equivalent" / "NOT equivalent" language for the generic-agent path',
|
||||
);
|
||||
assert.ok(
|
||||
hasWorkaroundLabel,
|
||||
'Section C must label the generic-agent path as a workaround or fallback',
|
||||
);
|
||||
|
||||
// AND: all three together
|
||||
assert.ok(
|
||||
namesTypedAgent && hasNotEquivalent && hasWorkaroundLabel,
|
||||
'Section C must AND: name a typed agent + label it NOT equivalent + call the generic path a workaround/fallback',
|
||||
);
|
||||
});
|
||||
|
||||
// (d) Fail-closed rule: when typed dispatch is mandatory, the adapter must
|
||||
// instruct the agent to fail closed and report the limitation, not silently degrade.
|
||||
test('(d) fail-closed: adapter requires failing closed when typed dispatch is mandatory', () => {
|
||||
const sectionC = getSectionC();
|
||||
|
||||
const hasFailClosed =
|
||||
sectionC.includes('fail closed') ||
|
||||
sectionC.includes('fail-closed') ||
|
||||
sectionC.includes('fail_closed');
|
||||
|
||||
const hasReportLimitation =
|
||||
sectionC.includes('schema limitation') ||
|
||||
sectionC.includes('report') ||
|
||||
sectionC.includes('not silently') ||
|
||||
sectionC.includes('silently degrading') ||
|
||||
sectionC.includes('silently');
|
||||
|
||||
const hasMandatoryContext =
|
||||
sectionC.includes('mandatory') ||
|
||||
sectionC.includes('required') ||
|
||||
sectionC.includes('worktree isolation') ||
|
||||
sectionC.includes('isolation');
|
||||
|
||||
assert.ok(
|
||||
hasFailClosed,
|
||||
'Section C must instruct fail-closed behavior (the phrase "fail closed" or equivalent)',
|
||||
);
|
||||
assert.ok(
|
||||
hasReportLimitation,
|
||||
'Section C must instruct reporting the schema limitation rather than silently degrading',
|
||||
);
|
||||
assert.ok(
|
||||
hasMandatoryContext,
|
||||
'Section C must identify a context where typed dispatch is mandatory (e.g. worktree isolation)',
|
||||
);
|
||||
|
||||
// AND: all three together
|
||||
assert.ok(
|
||||
hasFailClosed && hasReportLimitation && hasMandatoryContext,
|
||||
'Section C must AND: instruct fail-closed + report limitation + identify mandatory-typed-dispatch contexts',
|
||||
);
|
||||
});
|
||||
|
||||
// Regression guard: typed mapping for capable schema must still be present.
|
||||
test('adapter still documents typed agent_type spawn for sessions that support it', () => {
|
||||
const sectionC = getSectionC();
|
||||
|
||||
assert.ok(
|
||||
sectionC.includes('agent_type-capable') || sectionC.includes('multi_agent_v2'),
|
||||
'Section C must still document the typed schema (agent_type-capable / multi_agent_v2)',
|
||||
);
|
||||
assert.ok(
|
||||
sectionC.includes('spawn_agent(agent_type=') || sectionC.includes('agent_type="X"'),
|
||||
'Section C must still show a typed spawn_agent(agent_type=...) example for capable sessions',
|
||||
);
|
||||
});
|
||||
|
||||
// Regression guard: deferred tool discovery must remain (bug-279 contract).
|
||||
test('adapter deferred tool discovery instruction is preserved', () => {
|
||||
// The pre-existing bug-279 contract must remain intact
|
||||
assert.ok(
|
||||
src.includes('deferred') && src.includes('tool_search') && src.includes('spawn_agent'),
|
||||
'Adapter must still instruct deferred tool discovery via tool_search before deciding to run inline',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,190 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Bug #977: `resolveNodeRunner()` bakes an ephemeral fnm multishell shim path
|
||||
* (e.g. `C:/Users/u/AppData/Local/fnm_multishells/<pid>_<ts>/node.exe`) into
|
||||
* managed `.js` hook commands. fnm cleans up these per-shell-session directories
|
||||
* when the shell exits, so the captured path later points at nothing — every
|
||||
* managed hook fails to spawn until reinstall.
|
||||
*
|
||||
* Fix: when `normalizeNodePath` detects a path matching the fnm multishell
|
||||
* directory pattern (`fnm_multishells/<id>/node(\.exe)?$`), it probes a stable
|
||||
* alias path derived from `FNM_DIR` or `APPDATA` env vars (with injected
|
||||
* `existsSync` for testability) and returns the first that exists. Falls back to
|
||||
* the raw execPath if no stable alias is found.
|
||||
*
|
||||
* All assertions go against exported function return values — no source-grep.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
|
||||
const INSTALL = require(path.join(__dirname, '..', 'bin', 'install.js'));
|
||||
const { normalizeNodePath, resolveNodeRunner } = INSTALL;
|
||||
|
||||
// ─── Synthetic paths used across tests ───────────────────────────────────────
|
||||
|
||||
const EPHEMERAL_FNM_WIN = 'C:/Users/u/AppData/Local/fnm_multishells/15600_1781041703752/node.exe';
|
||||
const EPHEMERAL_FNM_WIN_BACKSLASH = 'C:\\Users\\u\\AppData\\Local\\fnm_multishells\\15600_1781041703752\\node.exe';
|
||||
const FNM_DIR_WIN = 'C:/Users/u/AppData/Roaming/fnm';
|
||||
const APPDATA_WIN = 'C:/Users/u/AppData/Roaming';
|
||||
const STABLE_FNM_DIR_NODE = `${FNM_DIR_WIN}/aliases/default/node.exe`;
|
||||
const STABLE_APPDATA_NODE = `${APPDATA_WIN}/fnm/aliases/default/node.exe`;
|
||||
|
||||
// ─── normalizeNodePath — fnm multishell ephemeral path → stable alias ────────
|
||||
|
||||
describe('Bug #977: normalizeNodePath — fnm multishell path with FNM_DIR → stable alias', () => {
|
||||
test('forward-slash Windows ephemeral path + FNM_DIR set + alias exists → stable FNM_DIR alias', () => {
|
||||
const result = normalizeNodePath(EPHEMERAL_FNM_WIN, {
|
||||
env: { FNM_DIR: FNM_DIR_WIN },
|
||||
existsSync: p => p === STABLE_FNM_DIR_NODE,
|
||||
});
|
||||
assert.equal(
|
||||
result,
|
||||
STABLE_FNM_DIR_NODE,
|
||||
`expected stable FNM_DIR alias, got: ${result}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('backslash Windows ephemeral path + FNM_DIR set + alias exists → stable FNM_DIR alias', () => {
|
||||
const result = normalizeNodePath(EPHEMERAL_FNM_WIN_BACKSLASH, {
|
||||
env: { FNM_DIR: FNM_DIR_WIN },
|
||||
existsSync: p => p === STABLE_FNM_DIR_NODE,
|
||||
});
|
||||
assert.equal(
|
||||
result,
|
||||
STABLE_FNM_DIR_NODE,
|
||||
`expected stable FNM_DIR alias, got: ${result}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('FNM_DIR alias does not exist → falls through to APPDATA alias → returns APPDATA alias', () => {
|
||||
const result = normalizeNodePath(EPHEMERAL_FNM_WIN, {
|
||||
env: { FNM_DIR: FNM_DIR_WIN, APPDATA: APPDATA_WIN },
|
||||
existsSync: p => p === STABLE_APPDATA_NODE, // FNM_DIR alias absent, APPDATA alias present
|
||||
});
|
||||
assert.equal(
|
||||
result,
|
||||
STABLE_APPDATA_NODE,
|
||||
`expected stable APPDATA alias, got: ${result}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('no alias exists → returns raw execPath unchanged (graceful fallback)', () => {
|
||||
const result = normalizeNodePath(EPHEMERAL_FNM_WIN, {
|
||||
env: { FNM_DIR: FNM_DIR_WIN, APPDATA: APPDATA_WIN },
|
||||
existsSync: () => false, // nothing exists
|
||||
});
|
||||
assert.equal(
|
||||
result,
|
||||
EPHEMERAL_FNM_WIN,
|
||||
`expected raw execPath fallback, got: ${result}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('no FNM_DIR or APPDATA in env → returns raw execPath unchanged', () => {
|
||||
const result = normalizeNodePath(EPHEMERAL_FNM_WIN, {
|
||||
env: {},
|
||||
existsSync: () => false,
|
||||
});
|
||||
assert.equal(
|
||||
result,
|
||||
EPHEMERAL_FNM_WIN,
|
||||
`expected raw execPath fallback, got: ${result}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── normalizeNodePath — non-fnm paths are NOT affected by the new branch ────
|
||||
|
||||
describe('Bug #977: normalizeNodePath — non-fnm paths are unaffected (no regression to existing behavior)', () => {
|
||||
test('NVM path is unchanged', () => {
|
||||
const nvm = '/Users/dev/.nvm/versions/node/v20.11.0/bin/node';
|
||||
assert.equal(normalizeNodePath(nvm), nvm);
|
||||
});
|
||||
|
||||
test('Intel Homebrew Cellar path still maps to stable symlink', () => {
|
||||
assert.equal(
|
||||
normalizeNodePath('/usr/local/Cellar/node/25.8.1/bin/node'),
|
||||
'/usr/local/bin/node',
|
||||
);
|
||||
});
|
||||
|
||||
test('Apple Silicon Homebrew Cellar path still maps to stable symlink', () => {
|
||||
assert.equal(
|
||||
normalizeNodePath('/opt/homebrew/Cellar/node/25.8.1/bin/node'),
|
||||
'/opt/homebrew/bin/node',
|
||||
);
|
||||
});
|
||||
|
||||
test('regular Windows nodejs path is unchanged', () => {
|
||||
const win = 'C:\\Program Files\\nodejs\\node.exe';
|
||||
assert.equal(normalizeNodePath(win), win);
|
||||
});
|
||||
|
||||
test('empty string is returned as-is', () => {
|
||||
assert.equal(normalizeNodePath(''), '');
|
||||
});
|
||||
|
||||
test('null is returned as-is', () => {
|
||||
assert.equal(normalizeNodePath(null), null);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── normalizeNodePath — already-stable fnm alias path is not re-processed ───
|
||||
|
||||
describe('Bug #977: normalizeNodePath — already-stable fnm alias path passes through unchanged', () => {
|
||||
test('stable FNM_DIR alias path is returned as-is', () => {
|
||||
assert.equal(
|
||||
normalizeNodePath(STABLE_FNM_DIR_NODE),
|
||||
STABLE_FNM_DIR_NODE,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── normalizeNodePath — false-positive guard: non-numeric id must NOT remap ──
|
||||
|
||||
describe('Bug #977: normalizeNodePath — non-ephemeral fnm_multishells path is not remapped', () => {
|
||||
test('non-numeric id segment (e.g. custom-dir) returns raw execPath unchanged even when alias exists', () => {
|
||||
const nonEphemeral = 'C:/Users/u/AppData/Local/fnm_multishells/custom-dir/node.exe';
|
||||
const stableAlias = 'C:/Users/u/AppData/Roaming/fnm/aliases/default/node.exe';
|
||||
const result = normalizeNodePath(nonEphemeral, {
|
||||
env: { FNM_DIR: 'C:/Users/u/AppData/Roaming/fnm' },
|
||||
// existsSync returns true for the alias to prove the regex — not the existsSync — is the guard
|
||||
existsSync: p => p === stableAlias,
|
||||
});
|
||||
assert.equal(
|
||||
result,
|
||||
nonEphemeral,
|
||||
`expected raw execPath (non-ephemeral id must not be remapped), got: ${result}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolveNodeRunner — opts pass-through ────────────────────────────────────
|
||||
|
||||
describe('Bug #977: resolveNodeRunner — passes opts through to normalizeNodePath', () => {
|
||||
test('fnm multishell execPath is resolved to stable alias via injected opts', () => {
|
||||
const orig = process.execPath;
|
||||
try {
|
||||
Object.defineProperty(process, 'execPath', {
|
||||
value: EPHEMERAL_FNM_WIN,
|
||||
configurable: true,
|
||||
});
|
||||
const runner = resolveNodeRunner({
|
||||
env: { FNM_DIR: FNM_DIR_WIN },
|
||||
existsSync: p => p === STABLE_FNM_DIR_NODE,
|
||||
});
|
||||
assert.equal(
|
||||
runner,
|
||||
JSON.stringify(STABLE_FNM_DIR_NODE),
|
||||
`expected stable FNM_DIR alias quoted, got: ${runner}`,
|
||||
);
|
||||
} finally {
|
||||
Object.defineProperty(process, 'execPath', { value: orig, configurable: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,338 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Regression tests for issue #983 — Trae and Windsurf converters leak
|
||||
* unreplaced bare `~/.claude` / `$HOME/.claude` references.
|
||||
*
|
||||
* Both converters rewrote only trailing-slash `.claude/` forms, so bare
|
||||
* home-path references (configDir = ~/.claude, $HOME/.claude) survived
|
||||
* conversion and pointed users at the wrong config dir.
|
||||
*
|
||||
* Fix: add bare word-boundary replacements mirroring Cline (#782) and
|
||||
* Codex (#570) precedent, with a negative lookahead to preserve `.claude-plugin`.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const {
|
||||
convertClaudeToWindsurfMarkdown,
|
||||
convertClaudeToTraeMarkdown,
|
||||
_applyRuntimeRewrites,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
// ─── Windsurf converter bare-form tests ─────────────────────────────────────
|
||||
|
||||
describe('convertClaudeToWindsurfMarkdown — bare ~/.claude and CLAUDE_CONFIG_DIR (#983)', () => {
|
||||
test('bare ~/.claude rewritten to ~/.windsurf (#1615: workspace dir is now .windsurf)', () => {
|
||||
const input = 'Config dir: (~/.claude), skills at ~/.claude/skills';
|
||||
const result = convertClaudeToWindsurfMarkdown(input);
|
||||
assert.ok(
|
||||
!/~\/\.claude(?![\w-])/.test(result),
|
||||
`bare ~/.claude must be rewritten; got: ${result}`,
|
||||
);
|
||||
assert.ok(result.includes('~/.windsurf'), 'must rewrite to ~/.windsurf');
|
||||
});
|
||||
|
||||
test('$HOME/.claude rewritten to $HOME/.windsurf (#1615: workspace dir is now .windsurf)', () => {
|
||||
const input = 'RUNTIME_CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"';
|
||||
const result = convertClaudeToWindsurfMarkdown(input);
|
||||
assert.ok(
|
||||
!/\$HOME\/\.claude(?![\w-])/.test(result),
|
||||
`bare $HOME/.claude must be rewritten; got: ${result}`,
|
||||
);
|
||||
assert.ok(result.includes('$HOME/.windsurf'), 'must rewrite to $HOME/.windsurf');
|
||||
});
|
||||
|
||||
test('CLAUDE_CONFIG_DIR rewritten to WINDSURF_CONFIG_DIR', () => {
|
||||
const input = 'Use CLAUDE_CONFIG_DIR or $HOME/.claude to configure';
|
||||
const result = convertClaudeToWindsurfMarkdown(input);
|
||||
assert.ok(
|
||||
result.includes('WINDSURF_CONFIG_DIR'),
|
||||
'CLAUDE_CONFIG_DIR must become WINDSURF_CONFIG_DIR',
|
||||
);
|
||||
assert.ok(
|
||||
!result.includes('CLAUDE_CONFIG_DIR'),
|
||||
'CLAUDE_CONFIG_DIR must be gone',
|
||||
);
|
||||
});
|
||||
|
||||
test('.claude-plugin is NOT corrupted (preserved as-is)', () => {
|
||||
const input = 'The .claude-plugin/plugin.json manifest enables plugin install.';
|
||||
const result = convertClaudeToWindsurfMarkdown(input);
|
||||
assert.ok(
|
||||
result.includes('.claude-plugin'),
|
||||
`.claude-plugin must be preserved; got: ${result}`,
|
||||
);
|
||||
assert.ok(
|
||||
!result.includes('.windsurf-plugin'),
|
||||
`.windsurf-plugin must not appear; got: ${result}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('no bare ~/.claude in converted surface.md', () => {
|
||||
const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md');
|
||||
const raw = fs.readFileSync(surfacePath, 'utf8');
|
||||
const result = convertClaudeToWindsurfMarkdown(raw);
|
||||
assert.ok(
|
||||
!/~\/\.claude(?![\w-])/.test(result),
|
||||
'converted surface.md must not contain bare ~/.claude',
|
||||
);
|
||||
});
|
||||
|
||||
test('no $HOME/.claude in converted surface.md', () => {
|
||||
const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md');
|
||||
const raw = fs.readFileSync(surfacePath, 'utf8');
|
||||
const result = convertClaudeToWindsurfMarkdown(raw);
|
||||
assert.ok(
|
||||
!/\$HOME\/\.claude(?![\w-])/.test(result),
|
||||
'converted surface.md must not contain bare $HOME/.claude',
|
||||
);
|
||||
});
|
||||
|
||||
test('no CLAUDE_CONFIG_DIR in converted surface.md', () => {
|
||||
const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md');
|
||||
const raw = fs.readFileSync(surfacePath, 'utf8');
|
||||
const result = convertClaudeToWindsurfMarkdown(raw);
|
||||
assert.ok(
|
||||
!result.includes('CLAUDE_CONFIG_DIR'),
|
||||
'converted surface.md must not contain CLAUDE_CONFIG_DIR',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Trae converter bare-form tests ─────────────────────────────────────────
|
||||
|
||||
describe('convertClaudeToTraeMarkdown — bare ~/.claude and CLAUDE_CONFIG_DIR (#983)', () => {
|
||||
test('bare ~/.claude rewritten to ~/.trae', () => {
|
||||
const input = 'Config dir: (~/.claude), skills at ~/.claude/skills';
|
||||
const result = convertClaudeToTraeMarkdown(input);
|
||||
assert.ok(
|
||||
!/~\/\.claude(?![\w-])/.test(result),
|
||||
`bare ~/.claude must be rewritten; got: ${result}`,
|
||||
);
|
||||
assert.ok(result.includes('~/.trae'), 'must rewrite to ~/.trae');
|
||||
});
|
||||
|
||||
test('$HOME/.claude rewritten to $HOME/.trae', () => {
|
||||
const input = 'RUNTIME_CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"';
|
||||
const result = convertClaudeToTraeMarkdown(input);
|
||||
assert.ok(
|
||||
!/\$HOME\/\.claude(?![\w-])/.test(result),
|
||||
`bare $HOME/.claude must be rewritten; got: ${result}`,
|
||||
);
|
||||
assert.ok(result.includes('$HOME/.trae'), 'must rewrite to $HOME/.trae');
|
||||
});
|
||||
|
||||
test('CLAUDE_CONFIG_DIR rewritten to TRAE_CONFIG_DIR', () => {
|
||||
const input = 'Use CLAUDE_CONFIG_DIR or $HOME/.claude to configure';
|
||||
const result = convertClaudeToTraeMarkdown(input);
|
||||
assert.ok(
|
||||
result.includes('TRAE_CONFIG_DIR'),
|
||||
'CLAUDE_CONFIG_DIR must become TRAE_CONFIG_DIR',
|
||||
);
|
||||
assert.ok(
|
||||
!result.includes('CLAUDE_CONFIG_DIR'),
|
||||
'CLAUDE_CONFIG_DIR must be gone',
|
||||
);
|
||||
});
|
||||
|
||||
test('.claude-plugin is NOT corrupted (preserved as-is)', () => {
|
||||
const input = 'The .claude-plugin/plugin.json manifest enables plugin install.';
|
||||
const result = convertClaudeToTraeMarkdown(input);
|
||||
assert.ok(
|
||||
result.includes('.claude-plugin'),
|
||||
`.claude-plugin must be preserved; got: ${result}`,
|
||||
);
|
||||
assert.ok(
|
||||
!result.includes('.trae-plugin'),
|
||||
`.trae-plugin must not appear; got: ${result}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('no bare ~/.claude in converted surface.md', () => {
|
||||
const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md');
|
||||
const raw = fs.readFileSync(surfacePath, 'utf8');
|
||||
const result = convertClaudeToTraeMarkdown(raw);
|
||||
assert.ok(
|
||||
!/~\/\.claude(?![\w-])/.test(result),
|
||||
'converted surface.md must not contain bare ~/.claude',
|
||||
);
|
||||
});
|
||||
|
||||
test('no $HOME/.claude in converted surface.md', () => {
|
||||
const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md');
|
||||
const raw = fs.readFileSync(surfacePath, 'utf8');
|
||||
const result = convertClaudeToTraeMarkdown(raw);
|
||||
assert.ok(
|
||||
!/\$HOME\/\.claude(?![\w-])/.test(result),
|
||||
'converted surface.md must not contain bare $HOME/.claude',
|
||||
);
|
||||
});
|
||||
|
||||
test('no CLAUDE_CONFIG_DIR in converted surface.md', () => {
|
||||
const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md');
|
||||
const raw = fs.readFileSync(surfacePath, 'utf8');
|
||||
const result = convertClaudeToTraeMarkdown(raw);
|
||||
assert.ok(
|
||||
!result.includes('CLAUDE_CONFIG_DIR'),
|
||||
'converted surface.md must not contain CLAUDE_CONFIG_DIR',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── _applyRuntimeRewrites install-path tests (windsurf) ────────────────────
|
||||
//
|
||||
// These tests exercise the ACTUAL install path that causes the user-facing leak.
|
||||
// The converter functions are called at stage time to produce a Windsurf-branded
|
||||
// copy, but _applyRuntimeRewrites is the path that runs at INSTALL time and
|
||||
// rewrites any surviving ~/.claude / $HOME/.claude refs in the staged files.
|
||||
//
|
||||
// FAIL-BEFORE proof: prior to this PR, windsurf used /~\/\.claude\b/ which
|
||||
// fires on "~/.claude-plugin" because \b matches between 'e' and '-'. Running
|
||||
// the test below against the old regex (`\b`) would:
|
||||
// - let bare $HOME/.claude survive (it used only /~\/\.claude\b/, missing $HOME form), AND
|
||||
// - corrupt "~/.claude-plugin" → "~/.windsurf-plugin".
|
||||
// Both assertions in the test below would fail on the old code.
|
||||
//
|
||||
// PASS-AFTER: the fix changes to (?![\w-]) so:
|
||||
// - bare ~/.claude / $HOME/.claude (not followed by word-char or hyphen) → rewritten
|
||||
// - ~/.claude-plugin preserved (the '-' after 'e' is in [\w-])
|
||||
//
|
||||
// NOTE on pathPrefix choice: we use '~/.windsurf/' (a simple home-relative
|
||||
// prefix) rather than '$HOME/.codeium/windsurf/' so that the corruption of
|
||||
// '~/.claude-plugin' → '~/.windsurf-plugin' is directly detectable via
|
||||
// result.includes('.windsurf-plugin').
|
||||
describe('_applyRuntimeRewrites(windsurf) — install-path bare-form + .claude-plugin (#983)', () => {
|
||||
// Use ~/ prefix (local-style) so that the .windsurf-plugin corruption is
|
||||
// directly detectable as a substring of the result.
|
||||
const WINDSURF_PATH_PREFIX = '~/.windsurf/';
|
||||
|
||||
// Compound content: covers every form the fix must handle.
|
||||
// IMPORTANT: we use ~/.claude-plugin (home-relative form) to exercise the
|
||||
// corruption that the old \b regex caused. The \b fires between 'e' and '-',
|
||||
// so ~/.claude-plugin → ~/.windsurf-plugin under the old code. That would
|
||||
// break the preservation assertion below. The (?![\w-]) fix prevents this.
|
||||
const COMPOUND_INPUT = [
|
||||
'Config dir: ~/.claude',
|
||||
'Also: $HOME/.claude',
|
||||
'Slash form: ~/.claude/skills/foo.md',
|
||||
'Plugin installed at: ~/.claude-plugin/plugin.json',
|
||||
'Env var: CLAUDE_CONFIG_DIR',
|
||||
].join('\n');
|
||||
|
||||
test('bare ~/.claude rewritten to ~/.windsurf (no trailing slash)', () => {
|
||||
const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX);
|
||||
assert.ok(
|
||||
!/~\/\.claude(?![\w-])/.test(result),
|
||||
`bare ~/.claude must be gone; got:\n${result}`,
|
||||
);
|
||||
assert.ok(
|
||||
result.includes('~/.windsurf'),
|
||||
`must contain normalized pathPrefix; got:\n${result}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('bare $HOME/.claude rewritten to ~/.windsurf (install-path normalizes both home forms)', () => {
|
||||
const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX);
|
||||
assert.ok(
|
||||
!/\$HOME\/\.claude(?![\w-])/.test(result),
|
||||
`bare $HOME/.claude must be gone; got:\n${result}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('zero surviving bare ~/.claude or $HOME/.claude refs in compound input', () => {
|
||||
const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX);
|
||||
const bareClaudePattern = /(?:~|\$HOME)\/\.claude(?![\w-])/;
|
||||
assert.ok(
|
||||
!bareClaudePattern.test(result),
|
||||
`no bare ~/.claude / $HOME/.claude must survive; got:\n${result}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('~/.claude-plugin is NOT corrupted to ~/.windsurf-plugin — was the \\b corruption', () => {
|
||||
// FAIL-BEFORE: old /~\/\.claude\b/ rewrote ~/.claude-plugin → ~/.windsurf-plugin
|
||||
// because \b fires between 'e' and '-'.
|
||||
// PASS-AFTER: (?![\w-]) sees '-' and skips the match, preserving ~/.claude-plugin.
|
||||
const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX);
|
||||
assert.ok(
|
||||
result.includes('~/.claude-plugin'),
|
||||
`~/.claude-plugin must be preserved; got:\n${result}`,
|
||||
);
|
||||
assert.ok(
|
||||
!result.includes('~/.windsurf-plugin'),
|
||||
`~/.windsurf-plugin must NOT appear (was the \\b corruption); got:\n${result}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('slash form ~/.claude/ is also rewritten (pre-existing coverage)', () => {
|
||||
const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX);
|
||||
assert.ok(
|
||||
!result.includes('~/.claude/'),
|
||||
`slash form ~/.claude/ must be gone; got:\n${result}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('CLAUDE_CONFIG_DIR is NOT rewritten by _applyRuntimeRewrites (converter responsibility)', () => {
|
||||
// _applyRuntimeRewrites does NOT handle CLAUDE_CONFIG_DIR for windsurf;
|
||||
// that rewrite is done by convertClaudeToWindsurfMarkdown at stage time.
|
||||
// This test documents the boundary and guards against scope creep.
|
||||
const result = _applyRuntimeRewrites(COMPOUND_INPUT, 'windsurf', WINDSURF_PATH_PREFIX);
|
||||
assert.ok(
|
||||
result.includes('CLAUDE_CONFIG_DIR'),
|
||||
'CLAUDE_CONFIG_DIR is not rewritten by _applyRuntimeRewrites — that is converter scope',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── _applyRuntimeRewrites install-path tests (trae) ────────────────────────
|
||||
//
|
||||
// Trae had bare-form handling before this PR (via \b) and the converter uses
|
||||
// (?![\w-]). The pre-existing \b in _applyRuntimeRewrites DOES corrupt
|
||||
// .claude-plugin → .trae-plugin (known limitation, out of scope for #983).
|
||||
// We document this here but do NOT assert preservation for trae, and we do NOT
|
||||
// fix the pre-existing trae \b lines (that would be a separate concern).
|
||||
//
|
||||
// What we DO assert: trae bare ~/.claude / $HOME/.claude refs are rewritten
|
||||
// (the install path cleans them), which is the core #983 fix for trae.
|
||||
describe('_applyRuntimeRewrites(trae) — install-path bare-form (#983)', () => {
|
||||
const TRAE_PATH_PREFIX = '$HOME/.trae/';
|
||||
|
||||
const TRAE_INPUT = [
|
||||
'Config dir: ~/.claude',
|
||||
'Also: $HOME/.claude',
|
||||
'Slash form: ~/.claude/skills/foo.md',
|
||||
// Note: .claude-plugin is intentionally omitted from assertions here because
|
||||
// the pre-existing trae case uses \b which corrupts it (known limitation,
|
||||
// out of scope for #983 — do not fix here).
|
||||
].join('\n');
|
||||
|
||||
test('bare ~/.claude rewritten to $HOME/.trae (trae install path)', () => {
|
||||
const result = _applyRuntimeRewrites(TRAE_INPUT, 'trae', TRAE_PATH_PREFIX);
|
||||
assert.ok(
|
||||
!/~\/\.claude(?![\w-])/.test(result),
|
||||
`bare ~/.claude must be gone; got:\n${result}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('bare $HOME/.claude rewritten to $HOME/.trae (trae install path)', () => {
|
||||
const result = _applyRuntimeRewrites(TRAE_INPUT, 'trae', TRAE_PATH_PREFIX);
|
||||
assert.ok(
|
||||
!/\$HOME\/\.claude(?![\w-])/.test(result),
|
||||
`bare $HOME/.claude must be gone; got:\n${result}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('slash form ~/.claude/ also rewritten (trae install path)', () => {
|
||||
const result = _applyRuntimeRewrites(TRAE_INPUT, 'trae', TRAE_PATH_PREFIX);
|
||||
assert.ok(
|
||||
!result.includes('~/.claude/'),
|
||||
`slash form ~/.claude/ must be gone; got:\n${result}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,427 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* ADR-857 phase 5f-3: extended hook event guards are driven by the
|
||||
* extendedHookEvents descriptor field, not hardcoded runtime-name checks.
|
||||
*
|
||||
* Before this change:
|
||||
* - SubagentStop/Stop/PreCompact were wired only when (isQwen || runtime==='claude')
|
||||
* - FileChanged was wired only when (runtime === 'claude')
|
||||
* - BeforeAgent/AfterAgent/BeforeModel were wired only when (isGemini)
|
||||
*
|
||||
* After this change:
|
||||
* - All three guard blocks are driven purely by extendedEvents.includes(eventName)
|
||||
* - Any runtime (or arbitrary string) that passes the right extendedHookEvents
|
||||
* array gets exactly those events registered, regardless of its runtime name.
|
||||
*
|
||||
* This suite proves descriptor-drive by calling applySettingsJsonHooks directly
|
||||
* with a controlled extendedHookEvents array and asserting on settings.hooks.
|
||||
* No source-grep; purely behavioral.
|
||||
*/
|
||||
|
||||
const { test, describe, before } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const HOOKS_DIST_DIR = path.join(REPO_ROOT, 'hooks', 'dist');
|
||||
const BUILD_HOOKS_SCRIPT = path.join(REPO_ROOT, 'scripts', 'build-hooks.js');
|
||||
|
||||
/** Idempotently ensure hooks/dist contains built .js files. */
|
||||
function ensureHooksDist() {
|
||||
if (!fs.existsSync(HOOKS_DIST_DIR) || fs.readdirSync(HOOKS_DIST_DIR).filter(f => f.endsWith('.js')).length === 0) {
|
||||
execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' });
|
||||
}
|
||||
}
|
||||
|
||||
before(() => {
|
||||
ensureHooksDist();
|
||||
});
|
||||
|
||||
const { applySettingsJsonHooks } = require('../bin/install.js');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Return all hook commands registered under an event key. */
|
||||
function hooksForEvent(settings, eventName) {
|
||||
if (!settings || !settings.hooks || !Array.isArray(settings.hooks[eventName])) return [];
|
||||
return settings.hooks[eventName].flatMap(entry =>
|
||||
(entry && Array.isArray(entry.hooks) ? entry.hooks : [])
|
||||
.map(h => h && h.command)
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
/** True if any hook is registered under eventName. */
|
||||
function hasHooksFor(settings, eventName) {
|
||||
return hooksForEvent(settings, eventName).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a temporary directory with stub hook files so fs.existsSync guards pass.
|
||||
* Returns the targetDir path.
|
||||
*/
|
||||
function createStubTargetDir() {
|
||||
const tmpDir = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'gsd-1076-'));
|
||||
const hooksDir = path.join(tmpDir, 'hooks');
|
||||
fs.mkdirSync(hooksDir, { recursive: true });
|
||||
// Stubs for the hooks applySettingsJsonHooks existsSync-checks
|
||||
const stubs = [
|
||||
'gsd-check-update.js',
|
||||
'gsd-context-monitor.js',
|
||||
'gsd-prompt-guard.js',
|
||||
'gsd-read-guard.js',
|
||||
'gsd-read-injection-scanner.js',
|
||||
'gsd-config-reload.js',
|
||||
'gsd-workflow-guard.js',
|
||||
'gsd-worktree-path-guard.js',
|
||||
'gsd-validate-commit.sh',
|
||||
'gsd-session-state.sh',
|
||||
'gsd-phase-boundary.sh',
|
||||
'gsd-graphify-update.sh',
|
||||
];
|
||||
const hooksDistDir = path.join(REPO_ROOT, 'hooks', 'dist');
|
||||
for (const stub of stubs) {
|
||||
const dest = path.join(hooksDir, stub);
|
||||
const distSrc = path.join(hooksDistDir, stub);
|
||||
if (fs.existsSync(distSrc)) {
|
||||
fs.copyFileSync(distSrc, dest);
|
||||
} else {
|
||||
// Minimal stub so existsSync passes
|
||||
const ext = path.extname(stub);
|
||||
fs.writeFileSync(dest, ext === '.sh' ? '#!/bin/bash\n# stub\n' : '#!/usr/bin/env node\n// stub\n');
|
||||
}
|
||||
try { fs.chmodSync(dest, 0o755); } catch { /* Windows */ }
|
||||
}
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
function cleanupDir(dir) {
|
||||
cleanup(dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the minimal opts bag for applySettingsJsonHooks.
|
||||
* postToolEvent: 'PostToolUse' (default dialect).
|
||||
* All commands: non-null strings so the "command truthy" guard passes.
|
||||
*/
|
||||
function buildOpts(targetDir, { runtime, extendedHookEvents }) {
|
||||
const hookOpts = { platform: process.platform, runtime };
|
||||
const node = process.execPath;
|
||||
return {
|
||||
runtime,
|
||||
isGlobal: true,
|
||||
targetDir,
|
||||
postToolEvent: 'PostToolUse',
|
||||
hookEvents: undefined, // not the hookEvents dialect — we're testing extendedHookEvents
|
||||
extendedHookEvents,
|
||||
updateCheckCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-check-update.js')}"`,
|
||||
contextMonitorCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-context-monitor.js')}"`,
|
||||
promptGuardCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-prompt-guard.js')}"`,
|
||||
readGuardCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-read-guard.js')}"`,
|
||||
readInjectionScannerCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-read-injection-scanner.js')}"`,
|
||||
configReloadCommand: `${node} "${path.join(targetDir, 'hooks', 'gsd-config-reload.js')}"`,
|
||||
hookOpts,
|
||||
localCmd: () => null,
|
||||
localShellCmd: () => null,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Suite 1: claude shape (SubagentStop+Stop+PreCompact+FileChanged) ─────────
|
||||
|
||||
describe('enh-1076 phase 5f-3: claude extendedHookEvents → SubagentStop/Stop/PreCompact/FileChanged', () => {
|
||||
let targetDir;
|
||||
let settings;
|
||||
|
||||
before(() => {
|
||||
targetDir = createStubTargetDir();
|
||||
settings = { hooks: {} };
|
||||
const opts = buildOpts(targetDir, {
|
||||
runtime: 'claude',
|
||||
extendedHookEvents: ['SubagentStop', 'Stop', 'PreCompact', 'FileChanged'],
|
||||
});
|
||||
applySettingsJsonHooks(settings, opts);
|
||||
});
|
||||
|
||||
test('SubagentStop is wired (descriptor-driven)', () => {
|
||||
assert.ok(
|
||||
hasHooksFor(settings, 'SubagentStop'),
|
||||
`Expected SubagentStop hooks; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}`
|
||||
);
|
||||
});
|
||||
|
||||
test('Stop is wired (descriptor-driven)', () => {
|
||||
assert.ok(
|
||||
hasHooksFor(settings, 'Stop'),
|
||||
`Expected Stop hooks; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}`
|
||||
);
|
||||
});
|
||||
|
||||
test('PreCompact is wired (descriptor-driven)', () => {
|
||||
assert.ok(
|
||||
hasHooksFor(settings, 'PreCompact'),
|
||||
`Expected PreCompact hooks; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}`
|
||||
);
|
||||
});
|
||||
|
||||
test('FileChanged is wired (descriptor-driven)', () => {
|
||||
assert.ok(
|
||||
hasHooksFor(settings, 'FileChanged'),
|
||||
`Expected FileChanged hooks; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}`
|
||||
);
|
||||
});
|
||||
|
||||
test('cleanup', () => {
|
||||
cleanupDir(targetDir);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 2: qwen shape (SubagentStop+Stop+PreCompact, no FileChanged) ───────
|
||||
|
||||
describe('enh-1076 phase 5f-3: qwen extendedHookEvents → SubagentStop/Stop/PreCompact only', () => {
|
||||
let targetDir;
|
||||
let settings;
|
||||
|
||||
before(() => {
|
||||
targetDir = createStubTargetDir();
|
||||
settings = { hooks: {} };
|
||||
const opts = buildOpts(targetDir, {
|
||||
runtime: 'qwen',
|
||||
extendedHookEvents: ['SubagentStop', 'Stop', 'PreCompact'],
|
||||
});
|
||||
applySettingsJsonHooks(settings, opts);
|
||||
});
|
||||
|
||||
test('SubagentStop is wired', () => {
|
||||
assert.ok(hasHooksFor(settings, 'SubagentStop'));
|
||||
});
|
||||
|
||||
test('Stop is wired', () => {
|
||||
assert.ok(hasHooksFor(settings, 'Stop'));
|
||||
});
|
||||
|
||||
test('PreCompact is wired', () => {
|
||||
assert.ok(hasHooksFor(settings, 'PreCompact'));
|
||||
});
|
||||
|
||||
test('FileChanged is NOT wired (not in extendedHookEvents)', () => {
|
||||
assert.strictEqual(
|
||||
hasHooksFor(settings, 'FileChanged'),
|
||||
false,
|
||||
`FileChanged must NOT be wired for qwen shape; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}`
|
||||
);
|
||||
});
|
||||
|
||||
test('cleanup', () => {
|
||||
cleanupDir(targetDir);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 3: gemini shape (BeforeAgent+AfterAgent+BeforeModel) ───────────────
|
||||
|
||||
describe('enh-1076 phase 5f-3: gemini extendedHookEvents → BeforeAgent/AfterAgent/BeforeModel', () => {
|
||||
let targetDir;
|
||||
let settings;
|
||||
|
||||
before(() => {
|
||||
targetDir = createStubTargetDir();
|
||||
settings = { hooks: {} };
|
||||
const opts = buildOpts(targetDir, {
|
||||
runtime: 'gemini',
|
||||
extendedHookEvents: ['BeforeAgent', 'AfterAgent', 'BeforeModel'],
|
||||
});
|
||||
applySettingsJsonHooks(settings, opts);
|
||||
});
|
||||
|
||||
test('BeforeAgent is wired', () => {
|
||||
assert.ok(
|
||||
hasHooksFor(settings, 'BeforeAgent'),
|
||||
`Expected BeforeAgent hooks; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}`
|
||||
);
|
||||
});
|
||||
|
||||
test('AfterAgent is wired', () => {
|
||||
assert.ok(hasHooksFor(settings, 'AfterAgent'));
|
||||
});
|
||||
|
||||
test('BeforeModel is wired', () => {
|
||||
assert.ok(hasHooksFor(settings, 'BeforeModel'));
|
||||
});
|
||||
|
||||
test('SubagentStop is NOT wired (not in extendedHookEvents)', () => {
|
||||
assert.strictEqual(
|
||||
hasHooksFor(settings, 'SubagentStop'),
|
||||
false,
|
||||
'SubagentStop must NOT be wired for gemini shape'
|
||||
);
|
||||
});
|
||||
|
||||
test('FileChanged is NOT wired (not in extendedHookEvents)', () => {
|
||||
assert.strictEqual(
|
||||
hasHooksFor(settings, 'FileChanged'),
|
||||
false,
|
||||
'FileChanged must NOT be wired for gemini shape'
|
||||
);
|
||||
});
|
||||
|
||||
test('cleanup', () => {
|
||||
cleanupDir(targetDir);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 4: empty extendedHookEvents → none of the extended events ──────────
|
||||
|
||||
describe('enh-1076 phase 5f-3: empty extendedHookEvents → no extended events wired', () => {
|
||||
let targetDir;
|
||||
let settings;
|
||||
|
||||
before(() => {
|
||||
targetDir = createStubTargetDir();
|
||||
settings = { hooks: {} };
|
||||
// Use runtime='someruntime' to prove it's the descriptor, not the name, that matters
|
||||
const opts = buildOpts(targetDir, {
|
||||
runtime: 'someruntime',
|
||||
extendedHookEvents: [],
|
||||
});
|
||||
applySettingsJsonHooks(settings, opts);
|
||||
});
|
||||
|
||||
const EXTENDED_EVENTS = [
|
||||
'SubagentStop', 'Stop', 'PreCompact', 'FileChanged',
|
||||
'BeforeAgent', 'AfterAgent', 'BeforeModel',
|
||||
];
|
||||
|
||||
for (const event of EXTENDED_EVENTS) {
|
||||
test(`${event} is NOT wired when extendedHookEvents is empty`, () => {
|
||||
assert.strictEqual(
|
||||
hasHooksFor(settings, event),
|
||||
false,
|
||||
`${event} must not be wired when extendedHookEvents=[] (runtime=someruntime); hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('cleanup', () => {
|
||||
cleanupDir(targetDir);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 5: descriptor-drive is runtime-name-agnostic ───────────────────────
|
||||
// Pass an arbitrary runtime name ('hypothetical') with SubagentStop in its
|
||||
// extendedHookEvents. This could NEVER have worked under the old hardcoded check.
|
||||
// Under the new descriptor-driven guard it MUST work.
|
||||
|
||||
describe('enh-1076 phase 5f-3: arbitrary runtime with SubagentStop in descriptor gets it wired', () => {
|
||||
let targetDir;
|
||||
let settings;
|
||||
|
||||
before(() => {
|
||||
targetDir = createStubTargetDir();
|
||||
settings = { hooks: {} };
|
||||
const opts = buildOpts(targetDir, {
|
||||
runtime: 'hypothetical', // NOT 'claude' or 'qwen' — would have been skipped before
|
||||
extendedHookEvents: ['SubagentStop'],
|
||||
});
|
||||
applySettingsJsonHooks(settings, opts);
|
||||
});
|
||||
|
||||
test('SubagentStop IS wired for a hypothetical runtime when descriptor includes it', () => {
|
||||
assert.ok(
|
||||
hasHooksFor(settings, 'SubagentStop'),
|
||||
`SubagentStop must be wired via descriptor even for unknown runtime names; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}`
|
||||
);
|
||||
});
|
||||
|
||||
test('Stop is NOT wired (not in extendedHookEvents)', () => {
|
||||
assert.strictEqual(hasHooksFor(settings, 'Stop'), false);
|
||||
});
|
||||
|
||||
test('cleanup', () => {
|
||||
cleanupDir(targetDir);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 6: hooksSurface drive (ADR-857 phase 5g drive 3) ──────────────────
|
||||
//
|
||||
// applySettingsJsonHooks is gated by opts.hooksSurface !== 'none'.
|
||||
// - hooksSurface:'none' → entire body is skipped; no hooks written
|
||||
// - hooksSurface:'settings-json'→ hooks are written (even for a runtime whose
|
||||
// name was previously hardcoded to skip, e.g. 'opencode')
|
||||
//
|
||||
// This proves the skip is driven by the descriptor field, not the runtime name.
|
||||
|
||||
describe('enh-1076 phase 5g drive 3: hooksSurface:none skips all hooks regardless of runtime', () => {
|
||||
let targetDir;
|
||||
let settings;
|
||||
|
||||
before(() => {
|
||||
targetDir = createStubTargetDir();
|
||||
settings = { hooks: {} };
|
||||
// 'claude' would normally write hooks, but hooksSurface:'none' must skip entirely.
|
||||
const opts = {
|
||||
...buildOpts(targetDir, { runtime: 'claude', extendedHookEvents: ['SubagentStop'] }),
|
||||
hooksSurface: 'none',
|
||||
};
|
||||
applySettingsJsonHooks(settings, opts);
|
||||
});
|
||||
|
||||
test('SessionStart is NOT written when hooksSurface is "none"', () => {
|
||||
assert.strictEqual(
|
||||
hasHooksFor(settings, 'SessionStart'),
|
||||
false,
|
||||
`SessionStart must not be written when hooksSurface="none"; hooks keys: ${JSON.stringify(Object.keys(settings.hooks || {}))}`
|
||||
);
|
||||
});
|
||||
|
||||
test('PostToolUse is NOT written when hooksSurface is "none"', () => {
|
||||
assert.strictEqual(hasHooksFor(settings, 'PostToolUse'), false);
|
||||
});
|
||||
|
||||
test('PreToolUse is NOT written when hooksSurface is "none"', () => {
|
||||
assert.strictEqual(hasHooksFor(settings, 'PreToolUse'), false);
|
||||
});
|
||||
|
||||
test('cleanup', () => {
|
||||
cleanupDir(targetDir);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enh-1076 phase 5g drive 3: hooksSurface:settings-json writes hooks even for previously-skipped runtime name', () => {
|
||||
let targetDir;
|
||||
let settings;
|
||||
|
||||
before(() => {
|
||||
targetDir = createStubTargetDir();
|
||||
settings = { hooks: {} };
|
||||
// 'opencode' previously was hardcoded to skip hooks; with descriptor drive it
|
||||
// should write hooks whenever hooksSurface !== 'none'.
|
||||
const opts = {
|
||||
...buildOpts(targetDir, { runtime: 'opencode', extendedHookEvents: [] }),
|
||||
hooksSurface: 'settings-json',
|
||||
};
|
||||
applySettingsJsonHooks(settings, opts);
|
||||
});
|
||||
|
||||
test('SessionStart IS written with at least one command when hooksSurface is "settings-json" (even for opencode name)', () => {
|
||||
// ensureHooksDist() in before() guarantees hooks/dist is built, so the
|
||||
// existsSync guards inside applySettingsJsonHooks pass and commands are registered.
|
||||
assert.ok(
|
||||
settings.hooks && typeof settings.hooks === 'object',
|
||||
`settings.hooks must be initialized when hooksSurface="settings-json"`,
|
||||
);
|
||||
assert.ok(
|
||||
hasHooksFor(settings, 'SessionStart'),
|
||||
`settings.hooks.SessionStart must contain at least one registered command when hooksSurface="settings-json"; ` +
|
||||
`keys: ${JSON.stringify(Object.keys(settings.hooks))}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('cleanup', () => {
|
||||
cleanupDir(targetDir);
|
||||
});
|
||||
});
|
||||
@@ -1,429 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* ADR-857 phase 5f-2: hook-events dialect is driven from the registry descriptor.
|
||||
*
|
||||
* Before this change, postToolEvent and preToolEvent were hardcoded strings
|
||||
* derived from runtime-name checks:
|
||||
*
|
||||
* (runtime === 'gemini' || runtime === 'antigravity') ? 'AfterTool' : 'PostToolUse'
|
||||
* (runtime === 'gemini' || runtime === 'antigravity') ? 'BeforeTool' : 'PreToolUse'
|
||||
*
|
||||
* After phase 5f-2, both are driven by the registry descriptor's
|
||||
* `hookEvents` field: hookEvents === 'gemini' → AfterTool/BeforeTool;
|
||||
* any other value (or missing) → PostToolUse/PreToolUse.
|
||||
*
|
||||
* Equivalence (i.e. identical observable behaviour for all runtimes):
|
||||
* hookEvents === 'gemini' iff runtime ∈ {gemini, antigravity}
|
||||
*
|
||||
* This suite asserts the equivalence and the registry-parity invariant:
|
||||
* any runtime whose descriptor carries hookEvents='gemini' gets the
|
||||
* AfterTool/BeforeTool dialect; all others get PostToolUse/PreToolUse.
|
||||
*/
|
||||
|
||||
const { test, describe, before, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const { install } = require('../bin/install.js');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
// ─── hooks/dist build guard ───────────────────────────────────────────────────
|
||||
//
|
||||
// hooks/dist/ is gitignored and only produced by `npm run build:hooks`.
|
||||
// In CI the scoped/windows test jobs do NOT run build:hooks before running
|
||||
// tests, so install() finds no hook files → event arrays come back empty →
|
||||
// every "expected AfterTool/PostToolUse/BeforeTool/PreToolUse hooks" assertion
|
||||
// fails. This mirrors the pattern in bug-376-claude-js-hook-gsd-rewriter.test.cjs.
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const HOOKS_DIST_DIR = path.join(REPO_ROOT, 'hooks', 'dist');
|
||||
const BUILD_HOOKS_SCRIPT = path.join(REPO_ROOT, 'scripts', 'build-hooks.js');
|
||||
|
||||
/**
|
||||
* Idempotently ensure hooks/dist contains built .js files.
|
||||
* Runs build-hooks.js only when the directory is absent or empty of .js files.
|
||||
*/
|
||||
function ensureHooksDist() {
|
||||
if (!fs.existsSync(HOOKS_DIST_DIR) || fs.readdirSync(HOOKS_DIST_DIR).filter(f => f.endsWith('.js')).length === 0) {
|
||||
execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' });
|
||||
}
|
||||
}
|
||||
|
||||
before(() => {
|
||||
ensureHooksDist();
|
||||
});
|
||||
|
||||
// ─── Registry lookup ──────────────────────────────────────────────────────────
|
||||
|
||||
const REGISTRY_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'capability-registry.cjs');
|
||||
const registry = (() => {
|
||||
try { return require(REGISTRY_PATH); } catch { return undefined; }
|
||||
})();
|
||||
|
||||
/**
|
||||
* Return the hookEvents dialect for a runtime ID from the live registry.
|
||||
* Returns undefined when the registry is absent or the runtime has no descriptor.
|
||||
*/
|
||||
function registryHookEvents(runtimeId) {
|
||||
return registry?.runtimes?.[runtimeId]?.runtime?.hookEvents;
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Collect all hook commands registered under a settings event key. */
|
||||
function hooksForEvent(settings, eventName) {
|
||||
if (!settings || !settings.hooks || !Array.isArray(settings.hooks[eventName])) return [];
|
||||
return settings.hooks[eventName].flatMap(entry =>
|
||||
(entry && Array.isArray(entry.hooks) ? entry.hooks : [])
|
||||
.map(h => h && h.command)
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
/** True if at least one hook is registered under eventName. */
|
||||
function hasHooksFor(settings, eventName) {
|
||||
return hooksForEvent(settings, eventName).length > 0;
|
||||
}
|
||||
|
||||
// ─── Suite 1: Gemini-dialect runtimes use AfterTool/BeforeTool ───────────────
|
||||
//
|
||||
// Registry runtimes with hookEvents='gemini': gemini, antigravity
|
||||
|
||||
describe('enh-1077 phase 5f-2: gemini hookEvents dialect → AfterTool/BeforeTool', () => {
|
||||
// ── gemini ──
|
||||
|
||||
describe('gemini install uses AfterTool for post-tool hooks', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
let settings;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-1077-gemini-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
|
||||
const geminiDir = path.join(tmpDir, '.gemini');
|
||||
fs.mkdirSync(geminiDir, { recursive: true });
|
||||
const result = install(false, 'gemini');
|
||||
settings = result && result.settings;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('registry confirms gemini hookEvents is "gemini"', () => {
|
||||
// Parity assertion: if the registry changes, this test fails first.
|
||||
const he = registryHookEvents('gemini');
|
||||
if (he !== undefined) {
|
||||
assert.strictEqual(he, 'gemini',
|
||||
'Registry descriptor for gemini must declare hookEvents="gemini"');
|
||||
}
|
||||
});
|
||||
|
||||
test('gemini install returns a settings object', () => {
|
||||
assert.ok(settings !== null && typeof settings === 'object',
|
||||
'gemini install must return a non-null settings object');
|
||||
});
|
||||
|
||||
test('gemini install registers at least one hook under AfterTool (post-tool)', () => {
|
||||
assert.ok(hasHooksFor(settings, 'AfterTool'),
|
||||
`Expected AfterTool hooks on gemini; got hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`);
|
||||
});
|
||||
|
||||
test('gemini install does NOT register context-monitor under PostToolUse (wrong dialect)', () => {
|
||||
const cmds = hooksForEvent(settings, 'PostToolUse');
|
||||
const hasMonitor = cmds.some(c => c && c.includes('gsd-context-monitor'));
|
||||
assert.strictEqual(hasMonitor, false,
|
||||
`gemini must NOT use PostToolUse for context-monitor; got PostToolUse commands: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
|
||||
test('gemini install registers at least one pre-tool hook (prompt-guard) under BeforeTool', () => {
|
||||
const cmds = hooksForEvent(settings, 'BeforeTool');
|
||||
const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard'));
|
||||
assert.ok(hasPromptGuard,
|
||||
`Expected prompt-guard hook under BeforeTool on gemini; BeforeTool commands: ${JSON.stringify(cmds)}; hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`);
|
||||
});
|
||||
|
||||
test('gemini install does NOT register prompt-guard under PreToolUse (wrong pre-tool dialect)', () => {
|
||||
const cmds = hooksForEvent(settings, 'PreToolUse');
|
||||
const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard'));
|
||||
assert.strictEqual(hasPromptGuard, false,
|
||||
`gemini must NOT use PreToolUse for prompt-guard; got PreToolUse commands: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ── antigravity ──
|
||||
|
||||
describe('antigravity install uses AfterTool/BeforeTool (gemini dialect)', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
let settings;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-1077-antigrav-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
|
||||
const agDir = path.join(tmpDir, '.gemini', 'antigravity');
|
||||
fs.mkdirSync(agDir, { recursive: true });
|
||||
const result = install(false, 'antigravity');
|
||||
settings = result && result.settings;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('registry confirms antigravity hookEvents is "gemini"', () => {
|
||||
const he = registryHookEvents('antigravity');
|
||||
if (he !== undefined) {
|
||||
assert.strictEqual(he, 'gemini',
|
||||
'Registry descriptor for antigravity must declare hookEvents="gemini"');
|
||||
}
|
||||
});
|
||||
|
||||
test('antigravity install returns a settings object', () => {
|
||||
assert.ok(settings !== null && typeof settings === 'object',
|
||||
'antigravity install must return a non-null settings object');
|
||||
});
|
||||
|
||||
test('antigravity install registers at least one hook under AfterTool', () => {
|
||||
assert.ok(hasHooksFor(settings, 'AfterTool'),
|
||||
`Expected AfterTool hooks on antigravity; got hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`);
|
||||
});
|
||||
|
||||
test('antigravity install does NOT register context-monitor under PostToolUse', () => {
|
||||
const cmds = hooksForEvent(settings, 'PostToolUse');
|
||||
const hasMonitor = cmds.some(c => c && c.includes('gsd-context-monitor'));
|
||||
assert.strictEqual(hasMonitor, false,
|
||||
`antigravity must NOT use PostToolUse for context-monitor; got: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
|
||||
test('antigravity install registers at least one pre-tool hook (prompt-guard) under BeforeTool', () => {
|
||||
const cmds = hooksForEvent(settings, 'BeforeTool');
|
||||
const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard'));
|
||||
assert.ok(hasPromptGuard,
|
||||
`Expected prompt-guard hook under BeforeTool on antigravity; BeforeTool commands: ${JSON.stringify(cmds)}; hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`);
|
||||
});
|
||||
|
||||
test('antigravity install does NOT register prompt-guard under PreToolUse (wrong pre-tool dialect)', () => {
|
||||
const cmds = hooksForEvent(settings, 'PreToolUse');
|
||||
const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard'));
|
||||
assert.strictEqual(hasPromptGuard, false,
|
||||
`antigravity must NOT use PreToolUse for prompt-guard; got PreToolUse commands: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 2: Claude-dialect runtimes use PostToolUse/PreToolUse ──────────────
|
||||
//
|
||||
// Registry runtimes with hookEvents='claude': claude, augment
|
||||
|
||||
describe('enh-1077 phase 5f-2: claude hookEvents dialect → PostToolUse/PreToolUse', () => {
|
||||
// ── claude ──
|
||||
|
||||
describe('claude install uses PostToolUse for post-tool hooks', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
let settings;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-1077-claude-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
|
||||
const claudeDir = path.join(tmpDir, '.claude');
|
||||
fs.mkdirSync(claudeDir, { recursive: true });
|
||||
const result = install(false, 'claude');
|
||||
settings = result && result.settings;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('registry confirms claude hookEvents is "claude"', () => {
|
||||
const he = registryHookEvents('claude');
|
||||
if (he !== undefined) {
|
||||
assert.strictEqual(he, 'claude',
|
||||
'Registry descriptor for claude must declare hookEvents="claude"');
|
||||
}
|
||||
});
|
||||
|
||||
test('claude install returns a settings object', () => {
|
||||
assert.ok(settings !== null && typeof settings === 'object',
|
||||
'claude install must return a non-null settings object');
|
||||
});
|
||||
|
||||
test('claude install registers at least one hook under PostToolUse', () => {
|
||||
assert.ok(hasHooksFor(settings, 'PostToolUse'),
|
||||
`Expected PostToolUse hooks on claude; got hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`);
|
||||
});
|
||||
|
||||
test('claude install does NOT register context-monitor under AfterTool (wrong dialect)', () => {
|
||||
const cmds = hooksForEvent(settings, 'AfterTool');
|
||||
const hasMonitor = cmds.some(c => c && c.includes('gsd-context-monitor'));
|
||||
assert.strictEqual(hasMonitor, false,
|
||||
`claude must NOT use AfterTool for context-monitor; got AfterTool commands: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
|
||||
test('claude install registers at least one pre-tool hook (prompt-guard) under PreToolUse', () => {
|
||||
const cmds = hooksForEvent(settings, 'PreToolUse');
|
||||
const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard'));
|
||||
assert.ok(hasPromptGuard,
|
||||
`Expected prompt-guard hook under PreToolUse on claude; PreToolUse commands: ${JSON.stringify(cmds)}; hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`);
|
||||
});
|
||||
|
||||
test('claude install does NOT register prompt-guard under BeforeTool (wrong pre-tool dialect)', () => {
|
||||
const cmds = hooksForEvent(settings, 'BeforeTool');
|
||||
const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard'));
|
||||
assert.strictEqual(hasPromptGuard, false,
|
||||
`claude must NOT use BeforeTool for prompt-guard; got BeforeTool commands: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ── augment ──
|
||||
|
||||
describe('augment install uses PostToolUse/PreToolUse (claude dialect)', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
let settings;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-1077-augment-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
|
||||
const augDir = path.join(tmpDir, '.augment');
|
||||
fs.mkdirSync(augDir, { recursive: true });
|
||||
const result = install(false, 'augment');
|
||||
settings = result && result.settings;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('registry confirms augment hookEvents is "claude"', () => {
|
||||
const he = registryHookEvents('augment');
|
||||
if (he !== undefined) {
|
||||
assert.strictEqual(he, 'claude',
|
||||
'Registry descriptor for augment must declare hookEvents="claude"');
|
||||
}
|
||||
});
|
||||
|
||||
test('augment install returns a settings object', () => {
|
||||
assert.ok(settings !== null && typeof settings === 'object',
|
||||
'augment install must return a non-null settings object');
|
||||
});
|
||||
|
||||
test('augment install registers at least one hook under PostToolUse', () => {
|
||||
assert.ok(hasHooksFor(settings, 'PostToolUse'),
|
||||
`Expected PostToolUse hooks on augment; got hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`);
|
||||
});
|
||||
|
||||
test('augment install does NOT register context-monitor under AfterTool', () => {
|
||||
const cmds = hooksForEvent(settings, 'AfterTool');
|
||||
const hasMonitor = cmds.some(c => c && c.includes('gsd-context-monitor'));
|
||||
assert.strictEqual(hasMonitor, false,
|
||||
`augment must NOT use AfterTool for context-monitor; got: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
|
||||
test('augment install registers at least one pre-tool hook (prompt-guard) under PreToolUse', () => {
|
||||
const cmds = hooksForEvent(settings, 'PreToolUse');
|
||||
const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard'));
|
||||
assert.ok(hasPromptGuard,
|
||||
`Expected prompt-guard hook under PreToolUse on augment; PreToolUse commands: ${JSON.stringify(cmds)}; hooks keys: ${JSON.stringify(Object.keys((settings && settings.hooks) || {}))}`);
|
||||
});
|
||||
|
||||
test('augment install does NOT register prompt-guard under BeforeTool (wrong pre-tool dialect)', () => {
|
||||
const cmds = hooksForEvent(settings, 'BeforeTool');
|
||||
const hasPromptGuard = cmds.some(c => c && c.includes('gsd-prompt-guard'));
|
||||
assert.strictEqual(hasPromptGuard, false,
|
||||
`augment must NOT use BeforeTool for prompt-guard; got BeforeTool commands: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 3: Registry-parity invariant ──────────────────────────────────────
|
||||
//
|
||||
// For every runtime in the registry that exposes a settings.json surface
|
||||
// (i.e. hookEvents is defined), assert that the installed hook dialect matches
|
||||
// the registry value. This is the generative-fix parity assertion
|
||||
// (DEFECT.GENERATIVE-FIX): adding a new runtime with hookEvents to the
|
||||
// registry automatically requires a passing install test for that runtime.
|
||||
|
||||
describe('enh-1077 phase 5f-2: registry-parity — hookEvents descriptor drives install dialect', () => {
|
||||
test('all registry runtimes with hookEvents use the matching install dialect', () => {
|
||||
if (!registry || !registry.runtimes) {
|
||||
// Registry absent — skip parity check (equivalence still verified above)
|
||||
return;
|
||||
}
|
||||
|
||||
// Runtimes that have settings.json surfaces and a hookEvents descriptor
|
||||
const SETTINGS_JSON_RUNTIMES = ['claude', 'gemini', 'antigravity', 'augment', 'qwen', 'hermes', 'codebuddy'];
|
||||
|
||||
const failures = [];
|
||||
|
||||
for (const runtimeId of SETTINGS_JSON_RUNTIMES) {
|
||||
const he = registryHookEvents(runtimeId);
|
||||
if (he === undefined) continue; // no hookEvents in descriptor — skip
|
||||
|
||||
const expectedPostEvent = he === 'gemini' ? 'AfterTool' : 'PostToolUse';
|
||||
const unexpectedPostEvent = he === 'gemini' ? 'PostToolUse' : 'AfterTool';
|
||||
const expectedPreEvent = he === 'gemini' ? 'BeforeTool' : 'PreToolUse';
|
||||
const unexpectedPreEvent = he === 'gemini' ? 'PreToolUse' : 'BeforeTool';
|
||||
|
||||
const previousCwd = process.cwd();
|
||||
const tmpDir = createTempDir(`gsd-1077-parity-${runtimeId}-`);
|
||||
try {
|
||||
process.chdir(tmpDir);
|
||||
const result = install(false, runtimeId);
|
||||
const settings = result && result.settings;
|
||||
if (!settings) continue; // non-settings-json surface, skip
|
||||
|
||||
// Post-tool event assertions
|
||||
const hasExpected = hasHooksFor(settings, expectedPostEvent);
|
||||
const hasUnexpected = hooksForEvent(settings, unexpectedPostEvent)
|
||||
.some(c => c && c.includes('gsd-context-monitor'));
|
||||
|
||||
if (!hasExpected) {
|
||||
failures.push(`${runtimeId}: expected context-monitor hook under ${expectedPostEvent} (hookEvents=${he}), but none found`);
|
||||
}
|
||||
if (hasUnexpected) {
|
||||
failures.push(`${runtimeId}: must NOT register context-monitor under ${unexpectedPostEvent}, but it was found`);
|
||||
}
|
||||
|
||||
// Pre-tool event assertions: prompt-guard must land under the dialect-correct key.
|
||||
const preToolCmdsExpected = hooksForEvent(settings, expectedPreEvent);
|
||||
const hasPromptGuardExpected = preToolCmdsExpected.some(c => c && c.includes('gsd-prompt-guard'));
|
||||
const preToolCmdsUnexpected = hooksForEvent(settings, unexpectedPreEvent);
|
||||
const hasPromptGuardUnexpected = preToolCmdsUnexpected.some(c => c && c.includes('gsd-prompt-guard'));
|
||||
|
||||
if (!hasPromptGuardExpected) {
|
||||
failures.push(`${runtimeId}: expected prompt-guard hook under ${expectedPreEvent} (hookEvents=${he}), but none found; ${expectedPreEvent} cmds: ${JSON.stringify(preToolCmdsExpected)}`);
|
||||
}
|
||||
if (hasPromptGuardUnexpected) {
|
||||
failures.push(`${runtimeId}: must NOT register prompt-guard under ${unexpectedPreEvent} (hookEvents=${he}), but it was found`);
|
||||
}
|
||||
} finally {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(failures, [],
|
||||
'Registry-parity failures (hookEvents descriptor must drive install dialect):\n' +
|
||||
failures.join('\n'));
|
||||
});
|
||||
});
|
||||
@@ -1,108 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// Enhancement #1510 (epic #1507, ADR-1508 Phase 1): behavior-preserving
|
||||
// relocation of pure rewrite-engine helpers out of hand-authored bin/install.js.
|
||||
// - getDirName -> gsd-core/bin/lib/runtime-name-policy.cjs
|
||||
// - processAttribution -> gsd-core/bin/lib/runtime-artifact-conversion.cjs
|
||||
// getCommitAttribution stays in install.js (impure install-time config I/O); the
|
||||
// convertClaudeToAugmentMarkdown duplicate dedup is deferred to Phase 2's cleanup
|
||||
// (entangled converter cluster; not required to unblock Phase 2).
|
||||
// These tests exercise the REAL relocated functions at their new home (the
|
||||
// generated .cjs) and assert install.js re-exports the SAME references
|
||||
// (Hyrum: existing consumers import these names from bin/install.js).
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
|
||||
const runtimeNamePolicy = require('../gsd-core/bin/lib/runtime-name-policy.cjs');
|
||||
const conversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs');
|
||||
const installer = require('../bin/install.js');
|
||||
|
||||
// ── Slice A: getDirName relocated to runtime-name-policy ──────────────────────
|
||||
describe('getDirName (relocated to runtime-name-policy)', () => {
|
||||
const EXPECTED = {
|
||||
claude: '.claude',
|
||||
copilot: '.github',
|
||||
opencode: '.opencode',
|
||||
gemini: '.gemini',
|
||||
kilo: '.kilo',
|
||||
codex: '.codex',
|
||||
antigravity: '.agents',
|
||||
cursor: '.cursor',
|
||||
windsurf: '.windsurf',
|
||||
augment: '.augment',
|
||||
trae: '.trae',
|
||||
qwen: '.qwen',
|
||||
hermes: '.hermes',
|
||||
kimi: '.kimi-code',
|
||||
codebuddy: '.codebuddy',
|
||||
cline: '.cline',
|
||||
};
|
||||
|
||||
for (const [runtime, dir] of Object.entries(EXPECTED)) {
|
||||
test(`maps '${runtime}' to '${dir}'`, () => {
|
||||
assert.strictEqual(runtimeNamePolicy.getDirName(runtime), dir);
|
||||
});
|
||||
}
|
||||
|
||||
test('falls back to .claude for an unknown runtime', () => {
|
||||
assert.strictEqual(runtimeNamePolicy.getDirName('definitely-not-a-runtime'), '.claude');
|
||||
});
|
||||
|
||||
test('falls back to .claude for empty input', () => {
|
||||
assert.strictEqual(runtimeNamePolicy.getDirName(''), '.claude');
|
||||
});
|
||||
|
||||
test('bin/install.js re-exports the SAME getDirName reference (no drift)', () => {
|
||||
assert.strictEqual(installer.getDirName, runtimeNamePolicy.getDirName);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Slice B: processAttribution relocated to runtime-artifact-conversion ───────
|
||||
describe('processAttribution (relocated to runtime-artifact-conversion)', () => {
|
||||
test('null removes the Co-Authored-By line and its preceding blank line', () => {
|
||||
const input = 'Commit body line.\n\nCo-Authored-By: Someone <s@example.com>';
|
||||
assert.strictEqual(conversion.processAttribution(input, null), 'Commit body line.');
|
||||
});
|
||||
|
||||
test('undefined leaves content unchanged', () => {
|
||||
const input = 'Commit body.\n\nCo-Authored-By: Someone <s@example.com>';
|
||||
assert.strictEqual(conversion.processAttribution(input, undefined), input);
|
||||
});
|
||||
|
||||
test('a string replaces the attribution value', () => {
|
||||
const input = 'Body\n\nCo-Authored-By: Old Name <old@example.com>';
|
||||
assert.strictEqual(
|
||||
conversion.processAttribution(input, 'New Name <new@example.com>'),
|
||||
'Body\n\nCo-Authored-By: New Name <new@example.com>',
|
||||
);
|
||||
});
|
||||
|
||||
test('escapes $ in the attribution to prevent backreference injection', () => {
|
||||
const input = 'Body\n\nCo-Authored-By: x';
|
||||
// "$1" must survive literally, not be interpreted as a regex backreference.
|
||||
assert.strictEqual(
|
||||
conversion.processAttribution(input, 'A $1 B'),
|
||||
'Body\n\nCo-Authored-By: A $1 B',
|
||||
);
|
||||
});
|
||||
|
||||
test('handles CRLF when removing (null)', () => {
|
||||
const input = 'Body\r\n\r\nCo-Authored-By: Someone <s@example.com>';
|
||||
assert.strictEqual(conversion.processAttribution(input, null), 'Body');
|
||||
});
|
||||
|
||||
test('replaces every Co-Authored-By line (global)', () => {
|
||||
const input = 'Body\nCo-Authored-By: A <a@x>\nCo-Authored-By: B <b@x>';
|
||||
assert.strictEqual(
|
||||
conversion.processAttribution(input, 'Z <z@x>'),
|
||||
'Body\nCo-Authored-By: Z <z@x>\nCo-Authored-By: Z <z@x>',
|
||||
);
|
||||
});
|
||||
|
||||
test('bin/install.js re-exports the SAME processAttribution reference (no drift)', () => {
|
||||
// processAttribution remains an explicit installer compatibility relay, so
|
||||
// the export must keep pointing at the conversion module's implementation.
|
||||
assert.strictEqual(installer.processAttribution, conversion.processAttribution);
|
||||
});
|
||||
});
|
||||
@@ -1,408 +0,0 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Tests for ADR-1508 Phase 2: rewrite engine relocation to runtime-artifact-conversion.
|
||||
* Issue #1511 — verifies the deep public seam signatures and behavior.
|
||||
*
|
||||
* Tests are behavioral (no source-grep). All filesystem operations use tmp dirs.
|
||||
*/
|
||||
|
||||
const { describe, test, before } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
let conversion;
|
||||
before(() => {
|
||||
process.env['GSD_TEST_MODE'] = '1';
|
||||
conversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// _computePathPrefix unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('_computePathPrefix', () => {
|
||||
test('global under home → $HOME/... form', () => {
|
||||
const prefix = conversion._computePathPrefix({
|
||||
isGlobal: true,
|
||||
isOpencode: false,
|
||||
isWindowsHost: false,
|
||||
resolvedTarget: '/home/u/.cursor',
|
||||
homeDir: '/home/u',
|
||||
});
|
||||
assert.equal(prefix, '$HOME/.cursor/');
|
||||
});
|
||||
|
||||
test('non-global → resolvedTarget/ form', () => {
|
||||
const prefix = conversion._computePathPrefix({
|
||||
isGlobal: false,
|
||||
isOpencode: false,
|
||||
isWindowsHost: false,
|
||||
resolvedTarget: '/project/.cursor',
|
||||
homeDir: '/home/u',
|
||||
});
|
||||
assert.equal(prefix, '/project/.cursor/');
|
||||
});
|
||||
|
||||
test('global opencode skips $HOME shorthand', () => {
|
||||
// OpenCode uses ~/.config/opencode which breaks $HOME shorthand in content
|
||||
const prefix = conversion._computePathPrefix({
|
||||
isGlobal: true,
|
||||
isOpencode: true,
|
||||
isWindowsHost: false,
|
||||
resolvedTarget: '/home/u/.config/opencode',
|
||||
homeDir: '/home/u',
|
||||
});
|
||||
assert.equal(prefix, '/home/u/.config/opencode/');
|
||||
});
|
||||
|
||||
test('global target outside home → resolvedTarget/ form', () => {
|
||||
const prefix = conversion._computePathPrefix({
|
||||
isGlobal: true,
|
||||
isOpencode: false,
|
||||
isWindowsHost: false,
|
||||
resolvedTarget: '/opt/custom-cursor',
|
||||
homeDir: '/home/u',
|
||||
});
|
||||
assert.equal(prefix, '/opt/custom-cursor/');
|
||||
});
|
||||
|
||||
test('isWindowsHost tripwire — Windows paths collapse to $HOME/ same as POSIX (no-op today)', () => {
|
||||
// Documents CURRENT behavior: isWindowsHost is accepted but not branched on.
|
||||
// Both win32=true and win32=false return '$HOME/.cursor/' for a home-relative target.
|
||||
// If a future Windows-specific branch is added, this tripwire fails and forces
|
||||
// an explicit decision about what to return on Windows.
|
||||
const withWindows = conversion._computePathPrefix({
|
||||
isGlobal: true,
|
||||
isOpencode: false,
|
||||
isWindowsHost: true,
|
||||
resolvedTarget: 'C:/Users/matte/.cursor',
|
||||
homeDir: 'C:/Users/matte',
|
||||
});
|
||||
const withoutWindows = conversion._computePathPrefix({
|
||||
isGlobal: true,
|
||||
isOpencode: false,
|
||||
isWindowsHost: false,
|
||||
resolvedTarget: 'C:/Users/matte/.cursor',
|
||||
homeDir: 'C:/Users/matte',
|
||||
});
|
||||
assert.equal(withWindows, '$HOME/.cursor/');
|
||||
assert.strictEqual(withWindows, withoutWindows);
|
||||
});
|
||||
|
||||
test('backslash-style resolvedTarget is normalized to forward slashes (#1615 regression)', () => {
|
||||
// path.join on Windows produces backslashes; the returned prefix is
|
||||
// substituted into markdown @-references which must use POSIX paths.
|
||||
// Without normalization the backslashes leak into workflow file content
|
||||
// and break substring checks on Windows CI.
|
||||
const prefix = conversion._computePathPrefix({
|
||||
isGlobal: false,
|
||||
isOpencode: false,
|
||||
isWindowsHost: true,
|
||||
resolvedTarget: 'C:\\Users\\runner\\AppData\\Local\\Temp\\gsd-1615-windsurf',
|
||||
homeDir: 'C:\\Users\\runner',
|
||||
});
|
||||
assert.strictEqual(prefix, 'C:/Users/runner/AppData/Local/Temp/gsd-1615-windsurf/');
|
||||
assert.ok(!prefix.includes('\\'), `prefix must not contain backslashes: ${prefix}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// _applyRuntimeRewrites with injected attribution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('_applyRuntimeRewrites — attribution injection', () => {
|
||||
const PREFIX = '$HOME/.cursor/';
|
||||
|
||||
test('attribution=null removes Co-Authored-By line', () => {
|
||||
const content = '# Hello\n\nSome text\n\nCo-Authored-By: Claude\n';
|
||||
const result = conversion._applyRuntimeRewrites(content, 'cursor', PREFIX, true, null);
|
||||
assert.ok(!result.includes('Co-Authored-By:'), 'Co-Authored-By should be removed');
|
||||
});
|
||||
|
||||
test('attribution=undefined leaves Co-Authored-By unchanged', () => {
|
||||
const content = '# Hello\n\nCo-Authored-By: Claude\n';
|
||||
const result = conversion._applyRuntimeRewrites(content, 'cursor', PREFIX, true, undefined);
|
||||
assert.ok(result.includes('Co-Authored-By: Claude'), 'Co-Authored-By should be preserved when attribution=undefined');
|
||||
});
|
||||
|
||||
test('attribution=string replaces Co-Authored-By value', () => {
|
||||
const content = '# Hello\n\nCo-Authored-By: OldName\n';
|
||||
const result = conversion._applyRuntimeRewrites(content, 'cursor', PREFIX, true, 'NewName <new@example.com>');
|
||||
assert.ok(result.includes('Co-Authored-By: NewName <new@example.com>'), 'Co-Authored-By should be replaced');
|
||||
});
|
||||
|
||||
test('cursor runtime replaces ~/.claude/ paths', () => {
|
||||
const content = 'See ~/.claude/skills/ for more info\n';
|
||||
const result = conversion._applyRuntimeRewrites(content, 'cursor', '/home/u/.cursor/', false, undefined);
|
||||
assert.ok(result.includes('/home/u/.cursor/skills/'), 'cursor should replace ~/.claude/ with pathPrefix');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// rewriteStagedSkillBodies — behavioral filesystem test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('rewriteStagedSkillBodies', () => {
|
||||
test('rewrites .md files in-place for cursor runtime', () => {
|
||||
const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-staged-'));
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-config-'));
|
||||
try {
|
||||
// Create a skill dir with a SKILL.md referencing ~/.claude/skills/foo
|
||||
// NOTE: the rewrite engine handles path replacement and attribution only.
|
||||
// Bash→Shell conversion is done by the stage-1 skill converter, not the engine.
|
||||
const skillDir = path.join(stagedDir, 'gsd-test-skill');
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
const content = '# Test\n\nSee ~/.claude/skills/foo\n\nAlso ~/.cursor/skills/bar\n';
|
||||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content);
|
||||
|
||||
// Call with injected homedir + platform for determinism
|
||||
conversion.rewriteStagedSkillBodies(stagedDir, {
|
||||
runtime: 'cursor',
|
||||
configDir,
|
||||
scope: 'global',
|
||||
homedir: () => '/home/u',
|
||||
platform: 'linux',
|
||||
});
|
||||
|
||||
const result = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8');
|
||||
// cursor rewrites ~/.claude/ → pathPrefix
|
||||
// configDir is a tmpdir, not under /home/u, so prefix = resolvedTarget + '/'
|
||||
// Mirror the engine's backslash→slash normalization so the assertion holds on Windows.
|
||||
const resolvedTarget = path.resolve(configDir).replace(/\\/g, '/');
|
||||
assert.ok(result.includes(`${resolvedTarget}/skills/foo`), `Should replace ~/.claude/skills/ with ${resolvedTarget}/skills/`);
|
||||
// cursor also rewrites ~/.cursor/ → pathPrefix
|
||||
assert.ok(result.includes(`${resolvedTarget}/skills/bar`), `Should replace ~/.cursor/skills/ with ${resolvedTarget}/skills/`);
|
||||
} finally {
|
||||
cleanup(stagedDir);
|
||||
cleanup(configDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('with injected homedir: global under home uses $HOME prefix', () => {
|
||||
// Real absolute path so Windows path.resolve does not re-root a POSIX literal onto a drive.
|
||||
// The dir need not exist — the engine only string-processes it.
|
||||
const HOME = path.resolve(os.tmpdir(), 'gsd-1511-fake-home');
|
||||
const configDir = path.join(HOME, '.cursor');
|
||||
const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-staged-'));
|
||||
try {
|
||||
const skillDir = path.join(stagedDir, 'gsd-help');
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), 'Use ~/.claude/skills/ here\n');
|
||||
|
||||
conversion.rewriteStagedSkillBodies(stagedDir, {
|
||||
runtime: 'cursor',
|
||||
configDir,
|
||||
scope: 'global',
|
||||
homedir: () => HOME,
|
||||
platform: process.platform,
|
||||
});
|
||||
|
||||
const result = fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8');
|
||||
assert.ok(result.includes('$HOME/.cursor/skills/'), 'Should use $HOME shorthand when configDir is under homedir');
|
||||
} finally {
|
||||
cleanup(stagedDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('non-existent stagedDir is a no-op', () => {
|
||||
assert.doesNotThrow(() => {
|
||||
conversion.rewriteStagedSkillBodies('/nonexistent/dir', {
|
||||
runtime: 'cursor',
|
||||
configDir: '/tmp/fake',
|
||||
scope: 'global',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// rewriteStagedCommandBodies — returns temp dir, does not mutate source
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('rewriteStagedCommandBodies', () => {
|
||||
test('returns a temp dir (not the source dir) with rewritten content', () => {
|
||||
const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-cmd-'));
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-config-'));
|
||||
let tempDir;
|
||||
try {
|
||||
// NOTE: rewrite engine handles path replacement + attribution, NOT tool renames.
|
||||
fs.writeFileSync(path.join(stagedDir, 'help.md'), '# Help\n\nSee ~/.claude/skills/\n\nSee ~/.cursor/skills/\n');
|
||||
|
||||
tempDir = conversion.rewriteStagedCommandBodies(stagedDir, {
|
||||
runtime: 'cursor',
|
||||
configDir,
|
||||
scope: 'global',
|
||||
homedir: () => '/home/u',
|
||||
platform: 'linux',
|
||||
});
|
||||
|
||||
assert.notEqual(tempDir, stagedDir, 'must return a different dir, never the source');
|
||||
assert.ok(fs.existsSync(tempDir), 'returned tempDir should exist');
|
||||
|
||||
const result = fs.readFileSync(path.join(tempDir, 'help.md'), 'utf8');
|
||||
// Source dir should be unchanged
|
||||
const source = fs.readFileSync(path.join(stagedDir, 'help.md'), 'utf8');
|
||||
assert.ok(source.includes('~/.claude/skills/'), 'source file must not be mutated');
|
||||
// configDir is /tmp/... (not under /home/u), so prefix = resolvedTarget + '/'
|
||||
const resolvedTarget = path.resolve(configDir).replace(/\\/g, '/');
|
||||
assert.ok(result.includes(`${resolvedTarget}/skills/`), 'output should have cursor path rewrite applied');
|
||||
// ~/.cursor/ also rewrites to prefix
|
||||
assert.ok(!result.includes('~/.cursor/'), 'output should have ~/.cursor/ replaced too');
|
||||
} finally {
|
||||
cleanup(stagedDir);
|
||||
cleanup(configDir);
|
||||
if (tempDir && tempDir !== stagedDir) {
|
||||
cleanup(tempDir);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('non-existent stagedDir returns stagedDir unchanged (safe)', () => {
|
||||
const result = conversion.rewriteStagedCommandBodies('/nonexistent/dir', {
|
||||
runtime: 'cursor',
|
||||
configDir: '/tmp/fake',
|
||||
scope: 'global',
|
||||
});
|
||||
assert.equal(result, '/nonexistent/dir', 'should return input path unchanged for missing dir');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error-path: applyRuntimeContentRewritesForCommandsInPlace must rm the tempDir
|
||||
// on any exception and NOT leave an orphaned gsd-cmd-rewrites-* directory.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('applyRuntimeContentRewritesForCommandsInPlace — error-path tempDir cleanup', () => {
|
||||
test('rmSync is called on the tempDir when readFileSync throws (deterministic monkeypatch)', () => {
|
||||
// Asserting the injected error propagates proves the throw happens AFTER the tempDir is
|
||||
// created (the function creates tempDir, then reads .md), so the catch's rmSync cleanup
|
||||
// is genuinely exercised — deterministic on every platform/uid.
|
||||
const stagedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-error-path-'));
|
||||
fs.writeFileSync(path.join(stagedDir, 'x.md'), '# test\n');
|
||||
|
||||
const before = new Set(
|
||||
fs.readdirSync(os.tmpdir()).filter(n => n.startsWith('gsd-cmd-rewrites-'))
|
||||
);
|
||||
|
||||
const origReadFileSync = fs.readFileSync;
|
||||
let leaked = [];
|
||||
try {
|
||||
fs.readFileSync = () => { throw new Error('injected read failure'); };
|
||||
|
||||
assert.throws(
|
||||
() => conversion.applyRuntimeContentRewritesForCommandsInPlace(stagedDir, 'cursor', '/tmp/x/', false),
|
||||
/injected read failure/,
|
||||
);
|
||||
|
||||
// Restore before any further fs use so the snapshot read is trustworthy.
|
||||
fs.readFileSync = origReadFileSync;
|
||||
|
||||
const after = fs.readdirSync(os.tmpdir()).filter(n => n.startsWith('gsd-cmd-rewrites-'));
|
||||
leaked = after.filter(n => !before.has(n));
|
||||
assert.deepStrictEqual(leaked, [], `tempDir not cleaned up on error: ${leaked.join(',')}`);
|
||||
} finally {
|
||||
// Idempotent restore — guard against early-throw paths above.
|
||||
fs.readFileSync = origReadFileSync;
|
||||
// Clean up the staged dir created for this test.
|
||||
cleanup(stagedDir);
|
||||
// Clean up any genuinely leaked gsd-cmd-rewrites-* dirs so the runner stays clean.
|
||||
for (const n of leaked) {
|
||||
cleanup(path.join(os.tmpdir(), n));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Guard: runtime-artifact-layout no longer exports getInstallExports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('layout module no longer exports getInstallExports', () => {
|
||||
test('getInstallExports is not on the layout module export', () => {
|
||||
process.env['GSD_TEST_MODE'] = '1';
|
||||
const layout = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs');
|
||||
assert.equal(
|
||||
typeof layout.getInstallExports,
|
||||
'undefined',
|
||||
'getInstallExports should have been removed from runtime-artifact-layout exports (ADR-1508 Phase 2)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DEFECT.GENERATIVE-FIX: single-owner reference-identity guard (#1511)
|
||||
// Proves install.js binds to the conversion module's implementation, not a
|
||||
// duplicate local copy. If these fail, a duplicate body was re-introduced.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('single-owner reference-identity guard (ADR-1508 / #1511 Phase 2)', () => {
|
||||
let install;
|
||||
let conversionCjs;
|
||||
before(() => {
|
||||
process.env['GSD_TEST_MODE'] = '1';
|
||||
install = require('../bin/install.js');
|
||||
conversionCjs = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs');
|
||||
});
|
||||
|
||||
test('install.computePathPrefix === conversion._computePathPrefix (single implementation)', () => {
|
||||
assert.strictEqual(
|
||||
install.computePathPrefix,
|
||||
conversionCjs._computePathPrefix,
|
||||
'install.js must bind computePathPrefix from conversion (not a duplicate body)',
|
||||
);
|
||||
});
|
||||
|
||||
test('install.applyRuntimeContentRewritesInPlace === conversion.applyRuntimeContentRewritesInPlace (single walk loop)', () => {
|
||||
assert.strictEqual(
|
||||
install.applyRuntimeContentRewritesInPlace,
|
||||
conversionCjs.applyRuntimeContentRewritesInPlace,
|
||||
'install.js must bind applyRuntimeContentRewritesInPlace from conversion (not a duplicate walk loop)',
|
||||
);
|
||||
});
|
||||
|
||||
test('install.applyRuntimeContentRewritesForCommandsInPlace === conversion.applyRuntimeContentRewritesForCommandsInPlace (single copy+rewrite loop)', () => {
|
||||
assert.strictEqual(
|
||||
install.applyRuntimeContentRewritesForCommandsInPlace,
|
||||
conversionCjs.applyRuntimeContentRewritesForCommandsInPlace,
|
||||
'install.js must bind applyRuntimeContentRewritesForCommandsInPlace from conversion (not a duplicate copy+rewrite loop)',
|
||||
);
|
||||
});
|
||||
|
||||
test('install._applyRuntimeRewrites === conversion._applyRuntimeRewrites (single switch engine)', () => {
|
||||
assert.strictEqual(
|
||||
install._applyRuntimeRewrites,
|
||||
conversionCjs._applyRuntimeRewrites,
|
||||
'install.js must bind _applyRuntimeRewrites from conversion (not a local shim)',
|
||||
);
|
||||
});
|
||||
|
||||
// #1675 (ADR-1508): the augment converter family is single-sourced in the
|
||||
// conversion module. install.js must re-bind (not re-define) these so there
|
||||
// is exactly one body — the generative-drift hazard the dedup removes.
|
||||
test('install.convertClaudeToAugmentMarkdown === conversion.convertClaudeToAugmentMarkdown (single converter)', () => {
|
||||
assert.strictEqual(
|
||||
install.convertClaudeToAugmentMarkdown,
|
||||
conversionCjs.convertClaudeToAugmentMarkdown,
|
||||
'install.js must bind convertClaudeToAugmentMarkdown from conversion (not a duplicate body)',
|
||||
);
|
||||
});
|
||||
|
||||
test('install.convertClaudeCommandToAugmentSkill === conversion.convertClaudeCommandToAugmentSkill (single converter)', () => {
|
||||
assert.strictEqual(
|
||||
install.convertClaudeCommandToAugmentSkill,
|
||||
conversionCjs.convertClaudeCommandToAugmentSkill,
|
||||
'install.js must bind convertClaudeCommandToAugmentSkill from conversion (not a duplicate body)',
|
||||
);
|
||||
});
|
||||
|
||||
test('install.convertClaudeAgentToAugmentAgent === conversion.convertClaudeAgentToAugmentAgent (single converter)', () => {
|
||||
assert.strictEqual(
|
||||
install.convertClaudeAgentToAugmentAgent,
|
||||
conversionCjs.convertClaudeAgentToAugmentAgent,
|
||||
'install.js must bind convertClaudeAgentToAugmentAgent from conversion (not a duplicate body)',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const { describe, test, before } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
let installer;
|
||||
let conversion;
|
||||
|
||||
before(() => {
|
||||
process.env['GSD_TEST_MODE'] = '1';
|
||||
installer = require('../bin/install.js');
|
||||
conversion = require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs');
|
||||
});
|
||||
|
||||
describe('bin/install.js compatibility export audit (#1559)', () => {
|
||||
test('retains audited compatibility relays for shared rewrite helpers', () => {
|
||||
assert.strictEqual(installer.processAttribution, conversion.processAttribution);
|
||||
assert.strictEqual(
|
||||
installer.applyRuntimeContentRewritesForCommandsInPlace,
|
||||
conversion.applyRuntimeContentRewritesForCommandsInPlace,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not leak unaudited conversion-module helpers through the installer', () => {
|
||||
for (const name of [
|
||||
'yamlQuote',
|
||||
'toSingleLine',
|
||||
'extractFrontmatterAndBody',
|
||||
'extractFrontmatterField',
|
||||
'convertClaudeToCursorMarkdown',
|
||||
'convertClaudeToCodexMarkdown',
|
||||
'transformContentToHyphen',
|
||||
'claudeToGeminiTools',
|
||||
'convertGeminiToolName',
|
||||
'rewriteStagedSkillBodies',
|
||||
'rewriteStagedCommandBodies',
|
||||
'_computePathPrefix',
|
||||
'_stampNonClaudeRuntimeDefaults',
|
||||
'NON_CLAUDE_RUNTIMES',
|
||||
]) {
|
||||
assert.ok(name in conversion, `${name} remains available from the conversion module`);
|
||||
assert.equal(installer[name], undefined, `${name} is not an installer compatibility export`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,351 +0,0 @@
|
||||
// allow-test-rule: integration-test-input
|
||||
// Exercises install() as a black-box by inspecting produced SKILL.md output
|
||||
// in a temp dir. Source command .md files are inputs whose installed
|
||||
// transformation is asserted — not inspected for string presence.
|
||||
|
||||
/**
|
||||
* #769 — effort: frontmatter on heavy workflow skills.
|
||||
* #921 — spawning orchestrators must NOT carry context: fork.
|
||||
*
|
||||
* Context: context:fork was added by #769 to protect context budget, but
|
||||
* plan-phase, execute-phase, and autonomous are spawning orchestrators — a
|
||||
* forked subagent has no Agent/Task tool, breaking their core function.
|
||||
* effort: max is preserved; context: fork is removed from these three.
|
||||
* The converter still passes context: fork through if a source file has it
|
||||
* (for any future leaf skill that legitimately needs isolation).
|
||||
*
|
||||
* Verifies:
|
||||
* 1. Source commands/gsd/autonomous.md does NOT have context: fork, has effort: max
|
||||
* 2. Source commands/gsd/execute-phase.md does NOT have context: fork, has effort: max
|
||||
* 3. Source commands/gsd/plan-phase.md does NOT have context: fork, has effort: max
|
||||
* 4. Source commands/gsd/progress.md has effort: low
|
||||
* 5. Source commands/gsd/stats.md has effort: low
|
||||
* 6. Claude global install: SKILL.md for autonomous has effort: max, NOT context: fork
|
||||
* 7. Claude global install: SKILL.md for execute-phase has effort: max, NOT context: fork
|
||||
* 8. Claude global install: SKILL.md for plan-phase has effort: max, NOT context: fork
|
||||
* 9. Claude global install: SKILL.md for progress has effort: low
|
||||
* 10. Claude global install: SKILL.md for stats has effort: low
|
||||
* 11. convertClaudeCommandToClaudeSkill still passes context: fork through (for non-orchestrator skills)
|
||||
* 12. convertClaudeCommandToClaudeSkill emits portable effort: field values
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const { install, convertClaudeCommandToClaudeSkill } = require('../bin/install.js');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
// #924: Claude global install is now FLAT — concrete skills are at the top level.
|
||||
// flatSkillPath returns: <skillsRoot>/gsd-<stem>/SKILL.md
|
||||
function flatSkillPath(skillsRoot, stem) {
|
||||
return path.join(skillsRoot, `gsd-${stem}`, 'SKILL.md');
|
||||
}
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const SOURCE_COMMANDS_DIR = path.join(REPO_ROOT, 'commands', 'gsd');
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeTmpDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function readFrontmatter(mdPath) {
|
||||
const content = fs.readFileSync(mdPath, 'utf8');
|
||||
if (!content.startsWith('---')) return '';
|
||||
const end = content.indexOf('---', 3);
|
||||
if (end === -1) return '';
|
||||
return content.substring(3, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a global install for Claude, redirecting its home dir to tmpHome.
|
||||
* Returns the tmpHome for inspection.
|
||||
*/
|
||||
function runClaudeGlobalInstall(claudeHome) {
|
||||
const isolatedHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-769-home-'));
|
||||
|
||||
const prevCwd = process.cwd();
|
||||
const prevClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
const prevHome = process.env.HOME;
|
||||
const prevUserProfile = process.env.USERPROFILE;
|
||||
const prevSkipStale = process.env.GSD_SKIP_STALE_SDK_CHECK;
|
||||
|
||||
process.env.CLAUDE_CONFIG_DIR = claudeHome;
|
||||
process.env.HOME = isolatedHome;
|
||||
process.env.USERPROFILE = isolatedHome;
|
||||
process.env.GSD_SKIP_STALE_SDK_CHECK = '1';
|
||||
process.chdir(REPO_ROOT);
|
||||
|
||||
try {
|
||||
install(true, 'claude');
|
||||
} finally {
|
||||
process.chdir(prevCwd);
|
||||
if (prevClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
||||
else process.env.CLAUDE_CONFIG_DIR = prevClaudeConfigDir;
|
||||
if (prevHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = prevHome;
|
||||
if (prevUserProfile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = prevUserProfile;
|
||||
if (prevSkipStale === undefined) delete process.env.GSD_SKIP_STALE_SDK_CHECK;
|
||||
else process.env.GSD_SKIP_STALE_SDK_CHECK = prevSkipStale;
|
||||
cleanup(isolatedHome);
|
||||
}
|
||||
|
||||
return claudeHome;
|
||||
}
|
||||
|
||||
// ─── describe 1: Source command files have correct frontmatter ────────────────
|
||||
|
||||
// #921/#922: spawning orchestrators must NOT carry context: fork — a forked
|
||||
// subagent has no Agent/Task tool, making it impossible for orchestrators to
|
||||
// spawn their required subagents. context: fork is appropriate only for leaf
|
||||
// skills that do not themselves dispatch agents. effort: max is portable across Claude Code models.
|
||||
describe('#769/#921/#1319 source commands: spawning orchestrators have effort: max but NOT context: fork', () => {
|
||||
test('commands/gsd/autonomous.md does NOT have context: fork (#921)', () => {
|
||||
const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'autonomous.md'));
|
||||
assert.doesNotMatch(fm, /^context:[ \t]*fork$/m,
|
||||
`autonomous.md is a spawning orchestrator and must NOT have context: fork (#921)\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('commands/gsd/autonomous.md has effort: max (#1319)', () => {
|
||||
const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'autonomous.md'));
|
||||
assert.match(fm, /^effort:[ \t]*max$/m,
|
||||
`autonomous.md frontmatter must have effort: max\nActual:\n${fm}`);
|
||||
assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m,
|
||||
`autonomous.md frontmatter must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('commands/gsd/execute-phase.md does NOT have context: fork (#921)', () => {
|
||||
const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'execute-phase.md'));
|
||||
assert.doesNotMatch(fm, /^context:[ \t]*fork$/m,
|
||||
`execute-phase.md is a spawning orchestrator and must NOT have context: fork (#921)\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('commands/gsd/execute-phase.md has effort: max (#1319)', () => {
|
||||
const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'execute-phase.md'));
|
||||
assert.match(fm, /^effort:[ \t]*max$/m,
|
||||
`execute-phase.md frontmatter must have effort: max\nActual:\n${fm}`);
|
||||
assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m,
|
||||
`execute-phase.md frontmatter must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('commands/gsd/plan-phase.md does NOT have context: fork (#921)', () => {
|
||||
const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'plan-phase.md'));
|
||||
assert.doesNotMatch(fm, /^context:[ \t]*fork$/m,
|
||||
`plan-phase.md is a spawning orchestrator and must NOT have context: fork (#921)\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('commands/gsd/plan-phase.md has effort: max (#1319)', () => {
|
||||
const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'plan-phase.md'));
|
||||
assert.match(fm, /^effort:[ \t]*max$/m,
|
||||
`plan-phase.md frontmatter must have effort: max\nActual:\n${fm}`);
|
||||
assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m,
|
||||
`plan-phase.md frontmatter must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#769 source commands: quick-status skills have effort: low', () => {
|
||||
test('commands/gsd/progress.md has effort: low', () => {
|
||||
const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'progress.md'));
|
||||
assert.match(fm, /^effort:[ \t]*low$/m,
|
||||
`progress.md frontmatter must have effort: low\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('commands/gsd/stats.md has effort: low', () => {
|
||||
const fm = readFrontmatter(path.join(SOURCE_COMMANDS_DIR, 'stats.md'));
|
||||
assert.match(fm, /^effort:[ \t]*low$/m,
|
||||
`stats.md frontmatter must have effort: low\nActual:\n${fm}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── describe 2: convertClaudeCommandToClaudeSkill preserves new fields ───────
|
||||
|
||||
describe('#769/#1319 convertClaudeCommandToClaudeSkill: preserves context and emits portable effort fields', () => {
|
||||
test('preserves context: fork in emitted SKILL.md frontmatter', () => {
|
||||
const input = [
|
||||
'---',
|
||||
'name: gsd:test-heavy',
|
||||
'description: Test heavy skill',
|
||||
'context: fork',
|
||||
'effort: xhigh',
|
||||
'allowed-tools:',
|
||||
' - Read',
|
||||
' - Bash',
|
||||
'---',
|
||||
'',
|
||||
'Heavy skill body.',
|
||||
].join('\n');
|
||||
|
||||
const result = convertClaudeCommandToClaudeSkill(input, 'test-heavy');
|
||||
const end = result.indexOf('---', 3);
|
||||
const fm = result.substring(3, end);
|
||||
|
||||
assert.match(fm, /^context:[ \t]*fork$/m,
|
||||
`SKILL.md frontmatter must include context: fork\nActual frontmatter:\n${fm}`);
|
||||
});
|
||||
|
||||
test('normalizes effort: xhigh to effort: max in emitted SKILL.md frontmatter (#1319)', () => {
|
||||
const input = [
|
||||
'---',
|
||||
'name: gsd:test-heavy',
|
||||
'description: Test heavy skill',
|
||||
'context: fork',
|
||||
'effort: xhigh',
|
||||
'allowed-tools:',
|
||||
' - Read',
|
||||
' - Bash',
|
||||
'---',
|
||||
'',
|
||||
'Heavy skill body.',
|
||||
].join('\n');
|
||||
|
||||
const result = convertClaudeCommandToClaudeSkill(input, 'test-heavy');
|
||||
const end = result.indexOf('---', 3);
|
||||
const fm = result.substring(3, end);
|
||||
|
||||
assert.match(fm, /^effort:[ \t]*max$/m,
|
||||
`SKILL.md frontmatter must include portable effort: max\nActual frontmatter:\n${fm}`);
|
||||
assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m,
|
||||
`SKILL.md frontmatter must not include rejected effort: xhigh (#1319)\nActual frontmatter:\n${fm}`);
|
||||
});
|
||||
|
||||
test('preserves effort: low in emitted SKILL.md frontmatter', () => {
|
||||
const input = [
|
||||
'---',
|
||||
'name: gsd:test-light',
|
||||
'description: Test light skill',
|
||||
'effort: low',
|
||||
'allowed-tools:',
|
||||
' - Read',
|
||||
'---',
|
||||
'',
|
||||
'Light skill body.',
|
||||
].join('\n');
|
||||
|
||||
const result = convertClaudeCommandToClaudeSkill(input, 'test-light');
|
||||
const end = result.indexOf('---', 3);
|
||||
const fm = result.substring(3, end);
|
||||
|
||||
assert.match(fm, /^effort:[ \t]*low$/m,
|
||||
`SKILL.md frontmatter must include effort: low\nActual frontmatter:\n${fm}`);
|
||||
});
|
||||
|
||||
test('does NOT emit context: or effort: when absent from source', () => {
|
||||
const input = [
|
||||
'---',
|
||||
'name: gsd:test-plain',
|
||||
'description: Plain skill without context or effort',
|
||||
'allowed-tools:',
|
||||
' - Read',
|
||||
'---',
|
||||
'',
|
||||
'Plain skill body.',
|
||||
].join('\n');
|
||||
|
||||
const result = convertClaudeCommandToClaudeSkill(input, 'test-plain');
|
||||
const end = result.indexOf('---', 3);
|
||||
const fm = result.substring(3, end);
|
||||
|
||||
assert.doesNotMatch(fm, /^context:/m,
|
||||
`SKILL.md must not emit context: when absent from source\nActual:\n${fm}`);
|
||||
assert.doesNotMatch(fm, /^effort:/m,
|
||||
`SKILL.md must not emit effort: when absent from source\nActual:\n${fm}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── describe 3: Claude global install — SKILL.md files include new fields ────
|
||||
|
||||
// #921/#922: after install, spawning orchestrators must NOT carry context: fork
|
||||
// in their emitted SKILL.md. #1319: heavyweight skills must use portable max effort.
|
||||
describe('#769/#921/#1319 Claude global install: spawning-orchestrator SKILL.md files have effort: max but NOT context: fork', () => {
|
||||
let tmpDir;
|
||||
let claudeHome;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = makeTmpDir('gsd-769-claude-');
|
||||
claudeHome = path.join(tmpDir, 'claude-home');
|
||||
fs.mkdirSync(claudeHome, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('gsd-autonomous SKILL.md does NOT have context: fork after global install (#921)', () => {
|
||||
runClaudeGlobalInstall(claudeHome);
|
||||
const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'autonomous');
|
||||
const fm = readFrontmatter(skillPath);
|
||||
assert.doesNotMatch(fm, /^context:[ \t]*fork$/m,
|
||||
`gsd-autonomous is a spawning orchestrator; its SKILL.md must NOT have context: fork (#921)\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('gsd-autonomous SKILL.md has effort: max after global install (#1319)', () => {
|
||||
runClaudeGlobalInstall(claudeHome);
|
||||
const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'autonomous');
|
||||
const fm = readFrontmatter(skillPath);
|
||||
assert.match(fm, /^effort:[ \t]*max$/m,
|
||||
`gsd-autonomous SKILL.md must have effort: max\nActual:\n${fm}`);
|
||||
assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m,
|
||||
`gsd-autonomous SKILL.md must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('gsd-execute-phase SKILL.md does NOT have context: fork after global install (#921)', () => {
|
||||
runClaudeGlobalInstall(claudeHome);
|
||||
const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'execute-phase');
|
||||
const fm = readFrontmatter(skillPath);
|
||||
assert.doesNotMatch(fm, /^context:[ \t]*fork$/m,
|
||||
`gsd-execute-phase is a spawning orchestrator; its SKILL.md must NOT have context: fork (#921)\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('gsd-execute-phase SKILL.md has effort: max after global install (#1319)', () => {
|
||||
runClaudeGlobalInstall(claudeHome);
|
||||
const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'execute-phase');
|
||||
const fm = readFrontmatter(skillPath);
|
||||
assert.match(fm, /^effort:[ \t]*max$/m,
|
||||
`gsd-execute-phase SKILL.md must have effort: max\nActual:\n${fm}`);
|
||||
assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m,
|
||||
`gsd-execute-phase SKILL.md must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('gsd-plan-phase SKILL.md does NOT have context: fork after global install (#921)', () => {
|
||||
runClaudeGlobalInstall(claudeHome);
|
||||
const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'plan-phase');
|
||||
const fm = readFrontmatter(skillPath);
|
||||
assert.doesNotMatch(fm, /^context:[ \t]*fork$/m,
|
||||
`gsd-plan-phase is a spawning orchestrator; its SKILL.md must NOT have context: fork (#921)\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('gsd-plan-phase SKILL.md has effort: max after global install (#1319)', () => {
|
||||
runClaudeGlobalInstall(claudeHome);
|
||||
const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'plan-phase');
|
||||
const fm = readFrontmatter(skillPath);
|
||||
assert.match(fm, /^effort:[ \t]*max$/m,
|
||||
`gsd-plan-phase SKILL.md must have effort: max\nActual:\n${fm}`);
|
||||
assert.doesNotMatch(fm, /^effort:[ \t]*xhigh$/m,
|
||||
`gsd-plan-phase SKILL.md must not have rejected effort: xhigh (#1319)\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('gsd-progress SKILL.md has effort: low after global install', () => {
|
||||
runClaudeGlobalInstall(claudeHome);
|
||||
const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'progress');
|
||||
const fm = readFrontmatter(skillPath);
|
||||
assert.match(fm, /^effort:[ \t]*low$/m,
|
||||
`gsd-progress SKILL.md must have effort: low\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('gsd-stats SKILL.md has effort: low after global install', () => {
|
||||
runClaudeGlobalInstall(claudeHome);
|
||||
const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'stats');
|
||||
const fm = readFrontmatter(skillPath);
|
||||
assert.match(fm, /^effort:[ \t]*low$/m,
|
||||
`gsd-stats SKILL.md must have effort: low\nActual:\n${fm}`);
|
||||
});
|
||||
});
|
||||
@@ -1,411 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Enhancement #772: Adopt new stable Codex hook events + commandWindows for
|
||||
* Windows parity.
|
||||
*
|
||||
* Codex CLI (rust-v0.137.0) stabilised the full hook-event set. This suite
|
||||
* asserts that a Codex install:
|
||||
*
|
||||
* (a) Registers the 3 new high-value hook events in hooks.json:
|
||||
* - SubagentStart — inject context / GSD_AGENT_NAME awareness at subagent open
|
||||
* - Stop — post-session context headroom tracking
|
||||
* - PostToolUse — mirror the Claude Code PostToolUse context monitor
|
||||
*
|
||||
* (b) Emits `commandWindows` in the SessionStart hooks.json entry so that
|
||||
* Windows users get the .cmd shim path and non-Windows users get the POSIX
|
||||
* node runner command. Both fields are present in the same entry; Codex picks
|
||||
* the right one per its HookHandlerConfig schema
|
||||
* (codex-rs/config/src/hook_config.rs: commandWindows / command_windows alias).
|
||||
*
|
||||
* Note: UserPromptSubmit is NOT wired (same rationale as Qwen #788 — the
|
||||
* gsd-prompt-guard handler exits unless tool_name is Write|Edit, so it would be
|
||||
* a silent no-op for the UserPromptSubmit payload shape).
|
||||
*
|
||||
* Test strategy:
|
||||
* - Test new event registration via ensureCodexHooksJsonEvent() directly
|
||||
* (mirrors the #3426 pattern of testing ensureCodexHooksJsonSessionStart
|
||||
* directly with a stub hook file — avoids full install() migration dance).
|
||||
* - Test commandWindows via ensureCodexHooksJsonSessionStart() directly.
|
||||
* - IR-first discipline: assert on the structured result, not rendered text.
|
||||
*
|
||||
* Verified hook event schema:
|
||||
* https://github.com/openai/codex/blob/main/codex-rs/protocol/src/protocol.rs
|
||||
* https://github.com/openai/codex/blob/main/codex/codex-rs/config/src/hook_config.rs
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const INSTALL = require('../bin/install.js');
|
||||
const {
|
||||
ensureCodexHooksJsonSessionStart,
|
||||
ensureCodexHooksJsonEvent,
|
||||
removeCodexHooksJsonEvent,
|
||||
reconcileCodexHooksJsonEvent,
|
||||
} = INSTALL;
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract all hook handler entries (full objects with type/command/etc.) for
|
||||
* `eventName` from a hooks.json object (flat or nested-hooks shape).
|
||||
*/
|
||||
function hooksJsonHandlersForEvent(hooksJson, eventName) {
|
||||
if (!hooksJson || typeof hooksJson !== 'object') return [];
|
||||
const table =
|
||||
hooksJson.hooks && typeof hooksJson.hooks === 'object' && !Array.isArray(hooksJson.hooks)
|
||||
? hooksJson.hooks
|
||||
: hooksJson;
|
||||
if (!Array.isArray(table[eventName])) return [];
|
||||
return table[eventName].flatMap(entry =>
|
||||
Array.isArray(entry && entry.hooks) ? entry.hooks : []
|
||||
);
|
||||
}
|
||||
|
||||
function readHooksJson(targetDir) {
|
||||
const p = path.join(targetDir, 'hooks.json');
|
||||
if (!fs.existsSync(p)) return null;
|
||||
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
||||
}
|
||||
|
||||
function stubHookFile(targetDir, hookName) {
|
||||
const hooksDest = path.join(targetDir, 'hooks');
|
||||
fs.mkdirSync(hooksDest, { recursive: true });
|
||||
const dest = path.join(hooksDest, hookName);
|
||||
if (!fs.existsSync(dest)) {
|
||||
fs.writeFileSync(dest, '#!/usr/bin/env node\n// stub\n');
|
||||
try { fs.chmodSync(dest, 0o755); } catch { /* Windows */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Suite 1: ensureCodexHooksJsonEvent export surface ───────────────────────
|
||||
|
||||
describe('enh-772: export surface — new functions are exported', () => {
|
||||
test('ensureCodexHooksJsonEvent is a function', () => {
|
||||
assert.strictEqual(typeof ensureCodexHooksJsonEvent, 'function',
|
||||
'ensureCodexHooksJsonEvent must be exported from bin/install.js');
|
||||
});
|
||||
|
||||
test('removeCodexHooksJsonEvent is a function', () => {
|
||||
assert.strictEqual(typeof removeCodexHooksJsonEvent, 'function',
|
||||
'removeCodexHooksJsonEvent must be exported from bin/install.js');
|
||||
});
|
||||
|
||||
test('reconcileCodexHooksJsonEvent is a function', () => {
|
||||
assert.strictEqual(typeof reconcileCodexHooksJsonEvent, 'function',
|
||||
'reconcileCodexHooksJsonEvent must be exported from bin/install.js');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 2: ensureCodexHooksJsonEvent registers new events ─────────────────
|
||||
|
||||
describe('enh-772: ensureCodexHooksJsonEvent registers SubagentStart, Stop, PostToolUse', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-772-events-');
|
||||
stubHookFile(tmpDir, 'gsd-context-monitor.js');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
for (const eventName of ['SubagentStart', 'Stop', 'PostToolUse']) {
|
||||
test(`${eventName}: ensureCodexHooksJsonEvent writes hooks.json`, () => {
|
||||
const fakeRunner = '"/usr/local/bin/node"';
|
||||
const result = ensureCodexHooksJsonEvent(tmpDir, eventName, {
|
||||
absoluteRunner: fakeRunner,
|
||||
platform: 'linux',
|
||||
});
|
||||
assert.ok(result && result.path, `result must have path for ${eventName}`);
|
||||
assert.ok(result.wrote || result.changed,
|
||||
`ensureCodexHooksJsonEvent must write or change hooks.json for ${eventName}`);
|
||||
assert.ok(fs.existsSync(path.join(tmpDir, 'hooks.json')),
|
||||
`hooks.json must exist after registering ${eventName}`);
|
||||
});
|
||||
|
||||
test(`${eventName}: hooks.json contains the event entry`, () => {
|
||||
const fakeRunner = '"/usr/local/bin/node"';
|
||||
ensureCodexHooksJsonEvent(tmpDir, eventName, {
|
||||
absoluteRunner: fakeRunner,
|
||||
platform: 'linux',
|
||||
});
|
||||
const hooksJson = readHooksJson(tmpDir);
|
||||
const handlers = hooksJsonHandlersForEvent(hooksJson, eventName);
|
||||
assert.ok(handlers.length > 0,
|
||||
`Expected ${eventName} entry in hooks.json; got: ${JSON.stringify(hooksJson)}`);
|
||||
});
|
||||
|
||||
test(`${eventName}: hook entry uses gsd-context-monitor`, () => {
|
||||
const fakeRunner = '"/usr/local/bin/node"';
|
||||
ensureCodexHooksJsonEvent(tmpDir, eventName, {
|
||||
absoluteRunner: fakeRunner,
|
||||
platform: 'linux',
|
||||
});
|
||||
const hooksJson = readHooksJson(tmpDir);
|
||||
const handlers = hooksJsonHandlersForEvent(hooksJson, eventName);
|
||||
assert.ok(
|
||||
handlers.some(h => h.command && h.command.includes('gsd-context-monitor')),
|
||||
`${eventName} hook must use gsd-context-monitor; got: ${JSON.stringify(handlers)}`
|
||||
);
|
||||
});
|
||||
|
||||
test(`${eventName}: hook entry has type: 'command'`, () => {
|
||||
const fakeRunner = '"/usr/local/bin/node"';
|
||||
ensureCodexHooksJsonEvent(tmpDir, eventName, {
|
||||
absoluteRunner: fakeRunner,
|
||||
platform: 'linux',
|
||||
});
|
||||
const hooksJson = readHooksJson(tmpDir);
|
||||
const handlers = hooksJsonHandlersForEvent(hooksJson, eventName);
|
||||
const entry = handlers.find(h => h.command && h.command.includes('gsd-context-monitor'));
|
||||
assert.strictEqual(entry && entry.type, 'command',
|
||||
`${eventName} hook entry must have type 'command'`);
|
||||
});
|
||||
|
||||
test(`${eventName}: hook entry has timeout: 10`, () => {
|
||||
const fakeRunner = '"/usr/local/bin/node"';
|
||||
ensureCodexHooksJsonEvent(tmpDir, eventName, {
|
||||
absoluteRunner: fakeRunner,
|
||||
platform: 'linux',
|
||||
});
|
||||
const hooksJson = readHooksJson(tmpDir);
|
||||
const handlers = hooksJsonHandlersForEvent(hooksJson, eventName);
|
||||
const entry = handlers.find(h => h.command && h.command.includes('gsd-context-monitor'));
|
||||
assert.strictEqual(entry && entry.timeout, 10,
|
||||
`${eventName} hook entry must have timeout 10`);
|
||||
});
|
||||
}
|
||||
|
||||
test('null absoluteRunner returns unchanged result without writing', () => {
|
||||
const result = ensureCodexHooksJsonEvent(tmpDir, 'SubagentStart', {
|
||||
absoluteRunner: null,
|
||||
platform: 'linux',
|
||||
});
|
||||
assert.strictEqual(result.changed, false,
|
||||
'null runner must return changed: false');
|
||||
assert.ok(!fs.existsSync(path.join(tmpDir, 'hooks.json')),
|
||||
'hooks.json must NOT be written when runner is null');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 3: commandWindows parity in SessionStart ──────────────────────────
|
||||
|
||||
describe('enh-772: commandWindows parity — ensureCodexHooksJsonSessionStart emits commandWindows', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-772-cmdwin-');
|
||||
stubHookFile(tmpDir, 'gsd-check-update.js');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
// commandWindows is ONLY emitted on win32 platform (where the .cmd shim is also
|
||||
// written). On POSIX platforms, commandWindows is omitted to avoid pointing Windows
|
||||
// Codex at a non-existent .cmd file (the shim is only present after a native Windows
|
||||
// install that runs buildCodexHookWindowsShimIR and atomicWriteFileSync).
|
||||
|
||||
test('POSIX platform: commandWindows is NOT emitted (shim not written on POSIX)', () => {
|
||||
const fakeRunner = '"/usr/local/bin/node"';
|
||||
const result = ensureCodexHooksJsonSessionStart(tmpDir, {
|
||||
absoluteRunner: fakeRunner,
|
||||
platform: 'linux',
|
||||
});
|
||||
assert.ok(result && result.wrote, 'must write hooks.json on linux');
|
||||
|
||||
const hooksJson = readHooksJson(tmpDir);
|
||||
const handlers = hooksJsonHandlersForEvent(hooksJson, 'SessionStart');
|
||||
assert.ok(handlers.length > 0, `Expected SessionStart handlers; got: ${JSON.stringify(hooksJson)}`);
|
||||
|
||||
const entry = handlers[0];
|
||||
assert.ok(
|
||||
entry.commandWindows === undefined,
|
||||
`commandWindows must NOT be emitted on POSIX (shim not written); got: ${JSON.stringify(entry)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('POSIX platform: command references gsd-check-update.js (not .cmd)', () => {
|
||||
const fakeRunner = '"/usr/local/bin/node"';
|
||||
ensureCodexHooksJsonSessionStart(tmpDir, {
|
||||
absoluteRunner: fakeRunner,
|
||||
platform: 'linux',
|
||||
});
|
||||
const hooksJson = readHooksJson(tmpDir);
|
||||
const handlers = hooksJsonHandlersForEvent(hooksJson, 'SessionStart');
|
||||
const entry = handlers[0];
|
||||
assert.ok(
|
||||
entry.command && entry.command.includes('gsd-check-update'),
|
||||
`POSIX command must reference gsd-check-update; got: ${entry.command}`
|
||||
);
|
||||
assert.ok(
|
||||
!entry.command.endsWith('.cmd') && !entry.command.endsWith('.cmd"'),
|
||||
`POSIX command must not end with .cmd; got: ${entry.command}`
|
||||
);
|
||||
});
|
||||
|
||||
test('null absoluteRunner: no commandWindows emitted, no write', () => {
|
||||
const result = ensureCodexHooksJsonSessionStart(tmpDir, {
|
||||
absoluteRunner: null,
|
||||
platform: 'linux',
|
||||
});
|
||||
assert.strictEqual(result.changed, false, 'null runner must return changed: false');
|
||||
const hooksJson = readHooksJson(tmpDir);
|
||||
if (hooksJson) {
|
||||
const handlers = hooksJsonHandlersForEvent(hooksJson, 'SessionStart');
|
||||
for (const h of handlers) {
|
||||
assert.ok(!h.commandWindows,
|
||||
`commandWindows must not be present when runner is null; got: ${JSON.stringify(h)}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Windows platform: SessionStart hook is written with commandWindows pointing to .cmd shim', () => {
|
||||
// On win32, both `command` and `commandWindows` use the .cmd shim path
|
||||
// (because managedCommand = shimIR.hookCommand = .cmd path, and
|
||||
// commandWindows = same .cmd path). This ensures Codex picks the .cmd
|
||||
// on Windows regardless of which field it reads.
|
||||
const fakeRunner = '"C:/Program Files/nodejs/node.exe"';
|
||||
const result = ensureCodexHooksJsonSessionStart(tmpDir, {
|
||||
absoluteRunner: fakeRunner,
|
||||
platform: 'win32',
|
||||
});
|
||||
// The shim write and hooks.json write should succeed in the tmp dir.
|
||||
if (result.wrote) {
|
||||
const hooksJson = readHooksJson(tmpDir);
|
||||
const handlers = hooksJsonHandlersForEvent(hooksJson, 'SessionStart');
|
||||
assert.ok(handlers.length > 0,
|
||||
`SessionStart must be registered on Windows path; got: ${JSON.stringify(hooksJson)}`);
|
||||
const entry = handlers[0];
|
||||
assert.ok(typeof entry.commandWindows === 'string',
|
||||
`commandWindows must be present on Windows path; got: ${JSON.stringify(entry)}`);
|
||||
// commandWindows should reference the .cmd shim
|
||||
assert.ok(
|
||||
entry.commandWindows.includes('gsd-check-update') && entry.commandWindows.includes('.cmd'),
|
||||
`commandWindows must reference gsd-check-update.cmd on win32; got: ${entry.commandWindows}`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 4: idempotency ────────────────────────────────────────────────────
|
||||
|
||||
describe('enh-772: ensureCodexHooksJsonEvent is idempotent', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-772-idem-');
|
||||
stubHookFile(tmpDir, 'gsd-context-monitor.js');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
for (const eventName of ['SubagentStart', 'Stop', 'PostToolUse']) {
|
||||
test(`${eventName}: calling twice does not duplicate hook entries`, () => {
|
||||
const fakeRunner = '"/usr/local/bin/node"';
|
||||
const opts = { absoluteRunner: fakeRunner, platform: 'linux' };
|
||||
|
||||
ensureCodexHooksJsonEvent(tmpDir, eventName, opts);
|
||||
ensureCodexHooksJsonEvent(tmpDir, eventName, opts);
|
||||
|
||||
const hooksJson = readHooksJson(tmpDir);
|
||||
const handlers = hooksJsonHandlersForEvent(hooksJson, eventName);
|
||||
assert.strictEqual(handlers.length, 1,
|
||||
`${eventName} should have exactly 1 hook handler after idempotent re-register; got ${handlers.length}: ${JSON.stringify(handlers)}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Suite 5: removeCodexHooksJsonEvent ──────────────────────────────────────
|
||||
|
||||
describe('enh-772: removeCodexHooksJsonEvent removes managed entries', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-772-remove-');
|
||||
stubHookFile(tmpDir, 'gsd-context-monitor.js');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
for (const eventName of ['SubagentStart', 'Stop', 'PostToolUse']) {
|
||||
test(`${eventName}: removeCodexHooksJsonEvent removes the managed entry`, () => {
|
||||
const fakeRunner = '"/usr/local/bin/node"';
|
||||
ensureCodexHooksJsonEvent(tmpDir, eventName, {
|
||||
absoluteRunner: fakeRunner,
|
||||
platform: 'linux',
|
||||
});
|
||||
|
||||
// Verify it was registered
|
||||
let hooksJson = readHooksJson(tmpDir);
|
||||
let handlers = hooksJsonHandlersForEvent(hooksJson, eventName);
|
||||
assert.ok(handlers.length > 0, `${eventName} must be registered before removal`);
|
||||
|
||||
// Remove
|
||||
const result = removeCodexHooksJsonEvent(tmpDir, eventName);
|
||||
assert.ok(result.changed || result.wrote,
|
||||
`removeCodexHooksJsonEvent must change hooks.json for ${eventName}`);
|
||||
|
||||
hooksJson = readHooksJson(tmpDir);
|
||||
if (hooksJson) {
|
||||
handlers = hooksJsonHandlersForEvent(hooksJson, eventName);
|
||||
assert.strictEqual(handlers.length, 0,
|
||||
`After removal, ${eventName} should have 0 handlers; got: ${JSON.stringify(handlers)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Suite 6: reconcileCodexHooksJsonEvent preserves user entries ─────────────
|
||||
|
||||
describe('enh-772: reconcileCodexHooksJsonEvent preserves user-owned entries', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-772-preserve-');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('user-owned SubagentStart entry is preserved when GSD entry is registered', () => {
|
||||
const hooksJsonPath = path.join(tmpDir, 'hooks.json');
|
||||
const userEntry = {
|
||||
hooks: [{ type: 'command', command: 'my-custom-hook.sh' }]
|
||||
};
|
||||
fs.writeFileSync(hooksJsonPath, JSON.stringify({
|
||||
SubagentStart: [userEntry]
|
||||
}, null, 2) + '\n');
|
||||
|
||||
reconcileCodexHooksJsonEvent(tmpDir, 'SubagentStart', {
|
||||
managedCommand: '"/usr/local/bin/node" "/home/me/.codex/hooks/gsd-context-monitor.js"',
|
||||
});
|
||||
|
||||
const hooksJson = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
|
||||
const table = hooksJson.hooks || hooksJson;
|
||||
const entries = Array.isArray(table.SubagentStart) ? table.SubagentStart : [];
|
||||
// Should have 2 entries: user entry + GSD entry
|
||||
assert.ok(entries.length >= 2,
|
||||
`User entry must be preserved; got entries: ${JSON.stringify(entries)}`);
|
||||
// User entry must still be present
|
||||
const userEntryStillPresent = entries.some(e =>
|
||||
Array.isArray(e.hooks) && e.hooks.some(h => h.command === 'my-custom-hook.sh')
|
||||
);
|
||||
assert.ok(userEntryStillPresent,
|
||||
`User entry must survive GSD registration; entries: ${JSON.stringify(entries)}`);
|
||||
});
|
||||
});
|
||||
@@ -1,406 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Enhancement #776: Adopt new Gemini hook events + detect hooksConfig.enabled:false.
|
||||
*
|
||||
* Gemini CLI exposes several hook events beyond BeforeTool/AfterTool that gsd
|
||||
* previously did not register. This suite asserts that a Gemini install
|
||||
* registers the 3 new high-value events:
|
||||
* - BeforeAgent — fires before the agent plans (context headroom tracking)
|
||||
* - AfterAgent — fires after final response generation (context tracking)
|
||||
* - BeforeModel — fires before each LLM call (per-turn context awareness)
|
||||
*
|
||||
* All three are wired to gsd-context-monitor.js — the same hook used for
|
||||
* AfterTool — so context headroom warnings surface at these lifecycle moments.
|
||||
*
|
||||
* Also asserts:
|
||||
* - Claude Code installs do NOT gain these Gemini-only events (strict scope guard).
|
||||
* - Reinstalls are idempotent (no hook duplication).
|
||||
* - Uninstall removes the new event registrations.
|
||||
* - hooksConfig.enabled:false warning is emitted during a Gemini install.
|
||||
*
|
||||
* Source: https://github.com/google-gemini/gemini-cli/blob/main/docs/hooks/reference.md
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { install, uninstall, validateHookFields } = require('../bin/install.js');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Extract all hook commands registered under `eventName` from settings. */
|
||||
function hooksForEvent(settings, eventName) {
|
||||
if (!settings || !settings.hooks || !Array.isArray(settings.hooks[eventName])) return [];
|
||||
return settings.hooks[eventName].flatMap(entry =>
|
||||
(entry && Array.isArray(entry.hooks) ? entry.hooks : [])
|
||||
.map(h => h && h.command)
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
// Stub JS hook files that the installer checks with fs.existsSync() so hook
|
||||
// registration guards pass even when hooks/dist/ isn't built.
|
||||
//
|
||||
// For Gemini, the installer migration baseline includes 'hooks/' in its surface
|
||||
// list (unlike Qwen which excludes it). Pre-install stubs placed in .gemini/hooks/
|
||||
// are classified as 'bundled-gsd-hook' and auto-removed by the migration before
|
||||
// registration can succeed. The workaround: run a first install (which writes the
|
||||
// gsd-file-manifest.json), then add the stubs, then run install again. On the
|
||||
// second install the manifest marks the hook files as managed, so migration keeps
|
||||
// them and registration guards (fs.existsSync) pass.
|
||||
const HOOKS_SRC = path.join(__dirname, '..', 'hooks');
|
||||
const STUB_HOOKS = [
|
||||
'gsd-context-monitor.js',
|
||||
'gsd-prompt-guard.js',
|
||||
'gsd-check-update.js',
|
||||
];
|
||||
|
||||
function stubHooksIntoTarget(targetDir) {
|
||||
const hooksDest = path.join(targetDir, 'hooks');
|
||||
fs.mkdirSync(hooksDest, { recursive: true });
|
||||
for (const hookFile of STUB_HOOKS) {
|
||||
const src = path.join(HOOKS_SRC, hookFile);
|
||||
const dest = path.join(hooksDest, hookFile);
|
||||
if (fs.existsSync(src)) {
|
||||
fs.copyFileSync(src, dest);
|
||||
} else {
|
||||
// Minimal stub so existsSync passes
|
||||
fs.writeFileSync(dest, '#!/usr/bin/env node\n// stub\n');
|
||||
}
|
||||
try { fs.chmodSync(dest, 0o755); } catch { /* Windows */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-pass Gemini install.
|
||||
*
|
||||
* Pass 1: install() with no hook stubs — writes gsd-file-manifest.json.
|
||||
* Migration runs on an empty hooks/ so nothing gets auto-removed.
|
||||
* Pass 2: stub hooks into the target dir (now manifest-tracked on next scan),
|
||||
* then run install() again. Migration now classifies the hooks as
|
||||
* managed-unchanged and preserves them; registration guards pass.
|
||||
*
|
||||
* Returns the settings from pass 2.
|
||||
*/
|
||||
function twoPassGeminiInstall(tmpDir) {
|
||||
const targetDir = path.join(tmpDir, '.gemini');
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
// Pass 1 — no hook stubs yet; writes the manifest
|
||||
const result1 = install(false, 'gemini');
|
||||
persistSettings(result1.settingsPath, result1.settings);
|
||||
|
||||
// Inject stubs so the registration guards (fs.existsSync) pass on pass 2
|
||||
stubHooksIntoTarget(targetDir);
|
||||
|
||||
// Pass 2 — manifest exists, migration keeps stubs, registration succeeds
|
||||
process.chdir(tmpDir);
|
||||
const result2 = install(false, 'gemini');
|
||||
return result2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist in-memory settings to disk, simulating what finishInstall() does
|
||||
* (finishInstall is not exported). Required for tests that call install()
|
||||
* twice and need the second call to read the first call's hook registrations.
|
||||
*/
|
||||
function persistSettings(settingsPath, settings) {
|
||||
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(validateHookFields(settings), null, 2) + '\n', 'utf8');
|
||||
}
|
||||
|
||||
// ─── Suite 1: Gemini — new events are registered ─────────────────────────────
|
||||
|
||||
describe('enh-776: Gemini install registers 3 new hook events', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
let settings;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-776-gemini-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
|
||||
// Two-pass install: first pass writes manifest, second pass with stubs
|
||||
// so registration guards (fs.existsSync) pass.
|
||||
const result = twoPassGeminiInstall(tmpDir);
|
||||
settings = result.settings;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('install returns a settings object (not null)', () => {
|
||||
assert.ok(settings !== null && typeof settings === 'object',
|
||||
'Gemini install must return a non-null settings object');
|
||||
});
|
||||
|
||||
test('BeforeAgent event is registered with at least one hook', () => {
|
||||
const cmds = hooksForEvent(settings, 'BeforeAgent');
|
||||
assert.ok(cmds.length > 0,
|
||||
`Expected BeforeAgent hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`);
|
||||
});
|
||||
|
||||
test('AfterAgent event is registered with at least one hook', () => {
|
||||
const cmds = hooksForEvent(settings, 'AfterAgent');
|
||||
assert.ok(cmds.length > 0,
|
||||
`Expected AfterAgent hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`);
|
||||
});
|
||||
|
||||
test('BeforeModel event is registered with at least one hook', () => {
|
||||
const cmds = hooksForEvent(settings, 'BeforeModel');
|
||||
assert.ok(cmds.length > 0,
|
||||
`Expected BeforeModel hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`);
|
||||
});
|
||||
|
||||
test('BeforeAgent / AfterAgent / BeforeModel all use gsd-context-monitor', () => {
|
||||
for (const event of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) {
|
||||
const cmds = hooksForEvent(settings, event);
|
||||
assert.ok(
|
||||
cmds.some(c => c.includes('gsd-context-monitor')),
|
||||
`Event ${event} should use gsd-context-monitor; got commands: ${JSON.stringify(cmds)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 2: Non-Gemini installs do NOT get the new events ──────────────────
|
||||
//
|
||||
// Two runtimes are particularly important to guard:
|
||||
// Claude — the canonical non-Gemini runtime
|
||||
// Antigravity — shares Gemini-style BeforeTool/AfterTool naming and uses
|
||||
// isGemini-adjacent logic; a future accidental
|
||||
// `isGemini || isAntigravity` change must be caught here.
|
||||
|
||||
describe('enh-776: Claude install does NOT register Gemini-only hook events', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
let settings;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-776-claude-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
|
||||
const result = install(false, 'claude');
|
||||
settings = result && result.settings;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('Claude install does not register BeforeAgent', () => {
|
||||
const cmds = hooksForEvent(settings, 'BeforeAgent');
|
||||
assert.strictEqual(cmds.length, 0,
|
||||
`Claude should NOT have BeforeAgent; got: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
|
||||
test('Claude install does not register AfterAgent', () => {
|
||||
const cmds = hooksForEvent(settings, 'AfterAgent');
|
||||
assert.strictEqual(cmds.length, 0,
|
||||
`Claude should NOT have AfterAgent; got: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
|
||||
test('Claude install does not register BeforeModel', () => {
|
||||
const cmds = hooksForEvent(settings, 'BeforeModel');
|
||||
assert.strictEqual(cmds.length, 0,
|
||||
`Claude should NOT have BeforeModel; got: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enh-776: Antigravity install does NOT register Gemini-only hook events', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
let settings;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-776-antigravity-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
|
||||
const result = install(false, 'antigravity');
|
||||
settings = result && result.settings;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('Antigravity install does not register BeforeAgent', () => {
|
||||
const cmds = hooksForEvent(settings, 'BeforeAgent');
|
||||
assert.strictEqual(cmds.length, 0,
|
||||
`Antigravity should NOT have BeforeAgent; got: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
|
||||
test('Antigravity install does not register AfterAgent', () => {
|
||||
const cmds = hooksForEvent(settings, 'AfterAgent');
|
||||
assert.strictEqual(cmds.length, 0,
|
||||
`Antigravity should NOT have AfterAgent; got: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
|
||||
test('Antigravity install does not register BeforeModel', () => {
|
||||
const cmds = hooksForEvent(settings, 'BeforeModel');
|
||||
assert.strictEqual(cmds.length, 0,
|
||||
`Antigravity should NOT have BeforeModel; got: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 3: Idempotency — persisted reinstall does not duplicate hooks ──────
|
||||
|
||||
describe('enh-776: Gemini install is idempotent across persisted reinstalls', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-776-idem-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('re-running after persisted first install does not duplicate hook entries', () => {
|
||||
// Two-pass to get hooks installed.
|
||||
const result2 = twoPassGeminiInstall(tmpDir);
|
||||
const s2 = result2.settings;
|
||||
|
||||
// Assert hooks ARE registered after pass 2 (guards against false-pass where
|
||||
// hooks never registered and idempotency passes trivially at count=0).
|
||||
for (const event of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) {
|
||||
const cmds = hooksForEvent(s2, event);
|
||||
assert.strictEqual(cmds.length, 1,
|
||||
`Event ${event} should have exactly 1 hook after two-pass install; got ${cmds.length}: ${JSON.stringify(cmds)}`);
|
||||
}
|
||||
|
||||
persistSettings(result2.settingsPath, s2);
|
||||
|
||||
// Third install: reads the persisted settings.json — dedup guards apply
|
||||
process.chdir(tmpDir);
|
||||
const result3 = install(false, 'gemini');
|
||||
const s3 = result3.settings;
|
||||
|
||||
for (const event of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) {
|
||||
const cmds = hooksForEvent(s3, event);
|
||||
assert.strictEqual(cmds.length, 1,
|
||||
`Event ${event} should have exactly 1 hook command after idempotent reinstall; got ${cmds.length}: ${JSON.stringify(cmds)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 4: Uninstall removes the new event registrations ──────────────────
|
||||
|
||||
describe('enh-776: Gemini uninstall removes new hook event entries', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-776-uninstall-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
|
||||
// Two-pass install and persist so uninstall has a settings.json to clean
|
||||
const result = twoPassGeminiInstall(tmpDir);
|
||||
persistSettings(result.settingsPath, result.settings);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('settings.json hook entries are removed on uninstall', () => {
|
||||
uninstall(false, 'gemini');
|
||||
const settingsPath = path.join(tmpDir, '.gemini', 'settings.json');
|
||||
if (!fs.existsSync(settingsPath)) return; // file removed entirely is fine
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
for (const event of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) {
|
||||
const cmds = hooksForEvent(settings, event);
|
||||
assert.strictEqual(cmds.length, 0,
|
||||
`After uninstall, ${event} should have 0 hooks; got: ${JSON.stringify(cmds)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 5: hooksConfig.enabled:false warning ───────────────────────────────
|
||||
|
||||
describe('enh-776: hooksConfig.enabled:false warning during Gemini install', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
let stderrLines;
|
||||
let originalWarn;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-776-hookscfg-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
|
||||
const targetDir = path.join(tmpDir, '.gemini');
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
// Capture console.warn output
|
||||
stderrLines = [];
|
||||
originalWarn = console.warn;
|
||||
console.warn = (...args) => { stderrLines.push(args.join(' ')); };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
console.warn = originalWarn;
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('emits a warning when hooksConfig.enabled is false', () => {
|
||||
// Write a settings.json with hooksConfig.enabled: false BEFORE install
|
||||
// (the check in install() reads the existing settings.json on disk).
|
||||
const targetDir = path.join(tmpDir, '.gemini');
|
||||
const settingsPath = path.join(targetDir, 'settings.json');
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ hooksConfig: { enabled: false } }, null, 2) + '\n', 'utf8');
|
||||
|
||||
install(false, 'gemini');
|
||||
const warnText = stderrLines.join('\n');
|
||||
assert.ok(
|
||||
warnText.includes('hooksConfig.enabled is false'),
|
||||
`Expected hooksConfig.enabled warning; got console.warn output:\n${warnText}`
|
||||
);
|
||||
});
|
||||
|
||||
test('does NOT emit the hooksConfig warning when hooksConfig.enabled is true', () => {
|
||||
const targetDir = path.join(tmpDir, '.gemini');
|
||||
const settingsPath = path.join(targetDir, 'settings.json');
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ hooksConfig: { enabled: true } }, null, 2) + '\n', 'utf8');
|
||||
|
||||
install(false, 'gemini');
|
||||
const warnText = stderrLines.join('\n');
|
||||
assert.ok(
|
||||
!warnText.includes('hooksConfig.enabled is false'),
|
||||
`Should NOT warn when hooksConfig.enabled is true; got:\n${warnText}`
|
||||
);
|
||||
});
|
||||
|
||||
test('does NOT emit the hooksConfig warning when hooksConfig is absent', () => {
|
||||
const targetDir = path.join(tmpDir, '.gemini');
|
||||
const settingsPath = path.join(targetDir, 'settings.json');
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({}, null, 2) + '\n', 'utf8');
|
||||
|
||||
install(false, 'gemini');
|
||||
const warnText = stderrLines.join('\n');
|
||||
assert.ok(
|
||||
!warnText.includes('hooksConfig.enabled is false'),
|
||||
`Should NOT warn when hooksConfig is absent; got:\n${warnText}`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,227 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Reads .md/SKILL.md/.toml product files whose deployed text IS what the
|
||||
// runtime loads — testing text content tests the deployed contract.
|
||||
|
||||
/**
|
||||
* GSD Tools Tests — #778 cross-runtime command enrichment.
|
||||
*
|
||||
* Two independently-verified, additive sub-features:
|
||||
* (b) Qwen Code skills: numeric `priority` field (higher sorts earlier in the
|
||||
* /skills TUI listing per the Qwen skills spec). Scoped to runtime='qwen'.
|
||||
* (c) Gemini custom-command TOML: $ARGUMENTS → {{args}} interpolation, and a
|
||||
* fixed `!{cat .planning/STATE.md}` live-state injection on the
|
||||
* situational `progress` command (injection-safe — no interpolated input).
|
||||
*
|
||||
* The OpenCode sub-feature (per-command model/agent/subtask/variant) is
|
||||
* intentionally NOT implemented — see PR description: `model` reintroduces the
|
||||
* #1156 ProviderModelNotFoundError regression for non-Anthropic OpenCode users,
|
||||
* `subtask`/`agent` change execution semantics for GSD's interactive commands,
|
||||
* and `variant` is not in the OpenCode command schema.
|
||||
*
|
||||
* Uses node:test and node:assert (NOT Jest).
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const {
|
||||
convertClaudeCommandToClaudeSkill,
|
||||
convertClaudeToGeminiMarkdown,
|
||||
install,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
// ─── (b) Qwen Code: priority ordering ───────────────────────────────────────
|
||||
|
||||
describe('#778 (b) Qwen skills priority', () => {
|
||||
const mk = (name, desc, body) =>
|
||||
['---', `name: gsd:${name}`, `description: ${desc}`, '---', '', body].join('\n');
|
||||
|
||||
test('emits numeric priority for a core-loop command (runtime=qwen)', () => {
|
||||
const result = convertClaudeCommandToClaudeSkill(
|
||||
mk('plan-phase', 'Plan a phase', 'Body.'),
|
||||
'gsd-plan-phase',
|
||||
'qwen',
|
||||
[]
|
||||
);
|
||||
const m = result.match(/^priority:\s*(\d+)\s*$/m);
|
||||
assert.ok(m, 'priority field present for gsd-plan-phase');
|
||||
assert.equal(Number(m[1]) > 0, true, 'priority is a positive number');
|
||||
});
|
||||
|
||||
test('core loop ranks higher than mid-tier (higher = earlier per spec)', () => {
|
||||
const np = convertClaudeCommandToClaudeSkill(
|
||||
mk('new-project', 'Start a project', 'Body.'), 'gsd-new-project', 'qwen', []
|
||||
).match(/^priority:\s*(\d+)/m);
|
||||
const help = convertClaudeCommandToClaudeSkill(
|
||||
mk('help', 'Help', 'Body.'), 'gsd-help', 'qwen', []
|
||||
).match(/^priority:\s*(\d+)/m);
|
||||
assert.ok(np && help, 'both core and mid-tier get a priority');
|
||||
assert.ok(
|
||||
Number(np[1]) > Number(help[1]),
|
||||
'new-project (core) sorts earlier than help (utility) — higher value'
|
||||
);
|
||||
});
|
||||
|
||||
test('utility command NOT in the priority map gets no priority field', () => {
|
||||
const result = convertClaudeCommandToClaudeSkill(
|
||||
mk('stats', 'Show stats', 'Body.'), 'gsd-stats', 'qwen', []
|
||||
);
|
||||
assert.ok(!/^priority:/m.test(result), 'no priority emitted for unmapped utility');
|
||||
});
|
||||
|
||||
test('does NOT emit priority for non-qwen runtimes (scoped to qwen)', () => {
|
||||
for (const rt of [null, 'claude', 'hermes']) {
|
||||
const result = convertClaudeCommandToClaudeSkill(
|
||||
mk('plan-phase', 'Plan a phase', 'Body.'), 'gsd-plan-phase', rt, []
|
||||
);
|
||||
assert.ok(!/^priority:/m.test(result), `no priority for runtime=${rt}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── (c) Gemini: {{args}} interpolation ─────────────────────────────────────
|
||||
|
||||
describe('#778 (c) Gemini {{args}} interpolation', () => {
|
||||
const cmd = (body) =>
|
||||
['---', 'name: gsd:demo', 'description: Demo', '---', '', body].join('\n');
|
||||
|
||||
test('maps $ARGUMENTS to {{args}} in the TOML prompt', () => {
|
||||
const out = convertClaudeToGeminiMarkdown(
|
||||
cmd('Operate on $ARGUMENTS now.'),
|
||||
{ isCommand: true, commandName: 'demo' }
|
||||
);
|
||||
assert.ok(out.includes('{{args}}'), '{{args}} present');
|
||||
assert.ok(!out.includes('$ARGUMENTS'), 'literal $ARGUMENTS removed');
|
||||
assert.ok(out.startsWith('description =') || out.includes('prompt ='), 'TOML shape');
|
||||
});
|
||||
|
||||
test('command without $ARGUMENTS gets no injected {{args}}', () => {
|
||||
const out = convertClaudeToGeminiMarkdown(
|
||||
cmd('No arguments referenced here.'),
|
||||
{ isCommand: true, commandName: 'demo' }
|
||||
);
|
||||
assert.ok(!out.includes('{{args}}'), 'no spurious {{args}}');
|
||||
});
|
||||
|
||||
test('non-command Gemini content is not TOML-converted and keeps $ARGUMENTS', () => {
|
||||
const out = convertClaudeToGeminiMarkdown(
|
||||
cmd('Reference $ARGUMENTS.'),
|
||||
{ isCommand: false }
|
||||
);
|
||||
// isCommand:false keeps markdown — no TOML wrap and no {{args}} mapping
|
||||
// (the $ARGUMENTS→{{args}} translation is scoped to the TOML command path).
|
||||
assert.ok(!out.startsWith('prompt ='), 'not wrapped as TOML prompt');
|
||||
assert.ok(out.includes('$ARGUMENTS'), '$ARGUMENTS left intact for non-command content');
|
||||
assert.ok(!out.includes('{{args}}'), 'no {{args}} injected outside the command path');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── (c) Gemini: end-to-end install wiring ──────────────────────────────────
|
||||
// Proves the install path derives the per-command name from the file stem so a
|
||||
// regression in the call-site wiring (not just the converter) is caught.
|
||||
|
||||
describe('#778 (c) Gemini install wiring (end-to-end)', () => {
|
||||
let tmpDir;
|
||||
let tmpHome;
|
||||
let prevCwd;
|
||||
let prevHome;
|
||||
let prevUserprofile;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-enh778-gem-');
|
||||
tmpHome = createTempDir('gsd-enh778-home-');
|
||||
prevCwd = process.cwd();
|
||||
prevHome = process.env.HOME;
|
||||
prevUserprofile = process.env.USERPROFILE;
|
||||
process.chdir(tmpDir);
|
||||
// Isolate HOME so a real ~/.gemini/commands/gsd/ doesn't trigger the #3037
|
||||
// local-install conflict-skip path.
|
||||
process.env.HOME = tmpHome;
|
||||
process.env.USERPROFILE = tmpHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(prevCwd);
|
||||
if (prevHome === undefined) delete process.env.HOME; else process.env.HOME = prevHome;
|
||||
if (prevUserprofile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = prevUserprofile;
|
||||
cleanup(tmpDir);
|
||||
cleanup(tmpHome);
|
||||
});
|
||||
|
||||
test('installed progress.toml carries the !{} block; arg-bearing commands get {{args}}', () => {
|
||||
const oldLog = console.log;
|
||||
console.log = () => {};
|
||||
try {
|
||||
install(false, 'gemini');
|
||||
} finally {
|
||||
console.log = oldLog;
|
||||
}
|
||||
|
||||
const commandsDir = path.join(tmpDir, '.gemini', 'commands', 'gsd');
|
||||
const progressToml = path.join(commandsDir, 'progress.toml');
|
||||
assert.ok(fs.existsSync(progressToml), 'progress.toml installed');
|
||||
const progress = fs.readFileSync(progressToml, 'utf8');
|
||||
// Proves commandName was derived as 'progress' from the file stem.
|
||||
assert.ok(
|
||||
progress.includes('!{cat .planning/STATE.md 2>/dev/null}'),
|
||||
'progress.toml has the live-state shell block'
|
||||
);
|
||||
|
||||
// A non-situational command must NOT receive the shell block.
|
||||
const helpToml = path.join(commandsDir, 'help.toml');
|
||||
if (fs.existsSync(helpToml)) {
|
||||
assert.ok(!fs.readFileSync(helpToml, 'utf8').includes('!{'), 'help.toml has no shell block');
|
||||
}
|
||||
|
||||
// At least one installed command must use {{args}} and none may retain a
|
||||
// literal $ARGUMENTS (every command body's $ARGUMENTS is translated).
|
||||
const tomls = fs.readdirSync(commandsDir).filter((f) => f.endsWith('.toml'));
|
||||
const withArgs = tomls.filter((f) =>
|
||||
fs.readFileSync(path.join(commandsDir, f), 'utf8').includes('{{args}}'));
|
||||
const withLiteral = tomls.filter((f) =>
|
||||
fs.readFileSync(path.join(commandsDir, f), 'utf8').includes('$ARGUMENTS'));
|
||||
assert.ok(withArgs.length > 0, 'at least one installed command interpolates {{args}}');
|
||||
assert.equal(withLiteral.length, 0, 'no installed command retains literal $ARGUMENTS');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── (c) Gemini: !{...} live-state injection (progress) ─────────────────────
|
||||
|
||||
describe('#778 (c) Gemini !{} live-state injection', () => {
|
||||
const cmd = (name) =>
|
||||
['---', `name: gsd:${name}`, `description: ${name}`, '---', '', 'Workflow body.'].join('\n');
|
||||
|
||||
test('progress command injects a fixed !{cat .planning/STATE.md} block', () => {
|
||||
const out = convertClaudeToGeminiMarkdown(
|
||||
cmd('progress'),
|
||||
{ isCommand: true, commandName: 'progress' }
|
||||
);
|
||||
assert.ok(out.includes('!{cat .planning/STATE.md'), 'STATE.md injection present');
|
||||
});
|
||||
|
||||
test('non-progress commands get no !{} shell block', () => {
|
||||
const out = convertClaudeToGeminiMarkdown(
|
||||
cmd('help'),
|
||||
{ isCommand: true, commandName: 'help' }
|
||||
);
|
||||
assert.ok(!out.includes('!{'), 'no shell block for non-situational command');
|
||||
});
|
||||
|
||||
test('SECURITY: the !{} block interpolates NO user input ({{args}})', () => {
|
||||
const out = convertClaudeToGeminiMarkdown(
|
||||
['---', 'name: gsd:progress', 'description: progress', '---', '',
|
||||
'Body uses $ARGUMENTS too.'].join('\n'),
|
||||
{ isCommand: true, commandName: 'progress' }
|
||||
);
|
||||
const blocks = out.match(/!\{([^}]*)\}/g) || [];
|
||||
assert.equal(blocks.length, 1, 'exactly one shell block');
|
||||
assert.ok(!/\{\{args\}\}/.test(blocks[0]), 'no {{args}} inside the shell block');
|
||||
assert.ok(/^!\{cat \.planning\/STATE\.md/.test(blocks[0]), 'fixed cat command only');
|
||||
});
|
||||
});
|
||||
@@ -1,280 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Enhancement #788: Expand Qwen Code hook-event coverage.
|
||||
*
|
||||
* Qwen Code supports 15 hook events; gsd previously registered only
|
||||
* SessionStart and PostToolUse. This suite asserts that a Qwen install
|
||||
* registers the 3 new high-value events:
|
||||
* - SubagentStop — subagent lifecycle finalisation (context tracking)
|
||||
* - Stop — model stop / final-response hook (context tracking)
|
||||
* - PreCompact — pre-compaction awareness (context tracking)
|
||||
*
|
||||
* All three are wired to gsd-context-monitor.js — the same hook used for
|
||||
* PostToolUse — so context headroom warnings surface at these moments too.
|
||||
*
|
||||
* Note: UserPromptSubmit is NOT wired — gsd-prompt-guard exits unless
|
||||
* tool_name is Write|Edit (PreToolUse shape), so it would be a no-op for
|
||||
* the UserPromptSubmit payload. Deferred to a follow-on issue.
|
||||
*
|
||||
* Also asserts the inverse: Claude Code installs do NOT gain these events
|
||||
* (strict isQwen scope guard).
|
||||
*
|
||||
* Source: https://qwenlm.github.io/qwen-code-docs/en/users/features/hooks/
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { install, uninstall, validateHookFields } = require('../bin/install.js');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Extract all hook commands registered under `eventName` from settings. */
|
||||
function hooksForEvent(settings, eventName) {
|
||||
if (!settings || !settings.hooks || !Array.isArray(settings.hooks[eventName])) return [];
|
||||
return settings.hooks[eventName].flatMap(entry =>
|
||||
(entry && Array.isArray(entry.hooks) ? entry.hooks : [])
|
||||
.map(h => h && h.command)
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
// Stub JS hook files that the installer checks with fs.existsSync() so hook
|
||||
// registration guards pass even when hooks/dist/ isn't built.
|
||||
const HOOKS_SRC = path.join(__dirname, '..', 'hooks');
|
||||
const STUB_HOOKS = [
|
||||
'gsd-context-monitor.js',
|
||||
'gsd-prompt-guard.js',
|
||||
'gsd-check-update.js',
|
||||
'gsd-config-reload.js', // Added in #770
|
||||
];
|
||||
|
||||
function stubHooksIntoTarget(targetDir) {
|
||||
const hooksDest = path.join(targetDir, 'hooks');
|
||||
fs.mkdirSync(hooksDest, { recursive: true });
|
||||
for (const hookFile of STUB_HOOKS) {
|
||||
const src = path.join(HOOKS_SRC, hookFile);
|
||||
const dest = path.join(hooksDest, hookFile);
|
||||
if (fs.existsSync(src)) {
|
||||
fs.copyFileSync(src, dest);
|
||||
} else {
|
||||
// Minimal stub so existsSync passes
|
||||
fs.writeFileSync(dest, '#!/usr/bin/env node\n// stub\n');
|
||||
}
|
||||
try { fs.chmodSync(dest, 0o755); } catch { /* Windows */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist in-memory settings to disk, simulating what finishInstall() does
|
||||
* (finishInstall is not exported). Required for tests that call install()
|
||||
* twice and need the second call to read the first call's hook registrations.
|
||||
*/
|
||||
function persistSettings(settingsPath, settings) {
|
||||
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(validateHookFields(settings), null, 2) + '\n', 'utf8');
|
||||
}
|
||||
|
||||
// ─── Suite 1: Qwen — new events are registered ───────────────────────────────
|
||||
|
||||
describe('enh-788: Qwen install registers 3 new hook events', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
let settings;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-788-qwen-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
|
||||
const targetDir = path.join(tmpDir, '.qwen');
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
// Pre-populate hook files so installer registration guards (fs.existsSync)
|
||||
// pass and hooks are actually registered in settings.json.
|
||||
stubHooksIntoTarget(targetDir);
|
||||
|
||||
const result = install(false, 'qwen');
|
||||
settings = result.settings;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('install returns a settings object (not null)', () => {
|
||||
assert.ok(settings !== null && typeof settings === 'object',
|
||||
'Qwen install must return a non-null settings object');
|
||||
});
|
||||
|
||||
test('SubagentStop event is registered with at least one hook', () => {
|
||||
const cmds = hooksForEvent(settings, 'SubagentStop');
|
||||
assert.ok(cmds.length > 0,
|
||||
`Expected SubagentStop hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`);
|
||||
});
|
||||
|
||||
test('Stop event is registered with at least one hook', () => {
|
||||
const cmds = hooksForEvent(settings, 'Stop');
|
||||
assert.ok(cmds.length > 0,
|
||||
`Expected Stop hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`);
|
||||
});
|
||||
|
||||
test('PreCompact event is registered with at least one hook', () => {
|
||||
const cmds = hooksForEvent(settings, 'PreCompact');
|
||||
assert.ok(cmds.length > 0,
|
||||
`Expected PreCompact hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`);
|
||||
});
|
||||
|
||||
test('UserPromptSubmit is NOT registered (handler not yet implemented for that payload shape)', () => {
|
||||
// gsd-prompt-guard exits unless tool_name is Write|Edit — it is a no-op
|
||||
// for UserPromptSubmit payloads. Registration is deferred until a
|
||||
// dedicated hook can process the user-prompt payload shape.
|
||||
const cmds = hooksForEvent(settings, 'UserPromptSubmit');
|
||||
assert.strictEqual(cmds.length, 0,
|
||||
`UserPromptSubmit should NOT be registered yet; got: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
|
||||
test('SubagentStop / Stop / PreCompact all use gsd-context-monitor', () => {
|
||||
for (const event of ['SubagentStop', 'Stop', 'PreCompact']) {
|
||||
const cmds = hooksForEvent(settings, event);
|
||||
assert.ok(
|
||||
cmds.some(c => c.includes('gsd-context-monitor')),
|
||||
`Event ${event} should use gsd-context-monitor; got commands: ${JSON.stringify(cmds)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('FileChanged is NOT registered for Qwen (Claude-only event)', () => {
|
||||
// gsd-config-reload / FileChanged is a Claude Code-only registration.
|
||||
// Qwen does not support the FileChanged hook event at all.
|
||||
const cmds = hooksForEvent(settings, 'FileChanged');
|
||||
assert.strictEqual(cmds.length, 0,
|
||||
`FileChanged should NOT be registered for Qwen; got: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 2: Claude install DOES get the context events (since #770) ───────
|
||||
// Note: Prior to #770, these were Qwen-only events. #770 extended them to
|
||||
// Claude Code. This suite is updated to match the new expected behavior.
|
||||
|
||||
describe('enh-788 (updated by #770): Claude install registers context lifecycle events', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
let settings;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-788-claude-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
stubHooksIntoTarget(path.join(tmpDir, '.claude'));
|
||||
|
||||
const result = install(false, 'claude', { installerMigrations: [] });
|
||||
settings = result && result.settings;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('Claude install registers SubagentStop (since #770)', () => {
|
||||
const cmds = hooksForEvent(settings, 'SubagentStop');
|
||||
assert.ok(cmds.length > 0,
|
||||
`Claude should have SubagentStop since #770; got: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
|
||||
test('Claude install registers Stop (since #770)', () => {
|
||||
const cmds = hooksForEvent(settings, 'Stop');
|
||||
assert.ok(cmds.length > 0,
|
||||
`Claude should have Stop since #770; got: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
|
||||
test('Claude install registers PreCompact (since #770)', () => {
|
||||
const cmds = hooksForEvent(settings, 'PreCompact');
|
||||
assert.ok(cmds.length > 0,
|
||||
`Claude should have PreCompact since #770; got: ${JSON.stringify(cmds)}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 3: Idempotency — persisted reinstall does not duplicate hooks ──────
|
||||
|
||||
describe('enh-788: Qwen install is idempotent across persisted reinstalls', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-788-idem-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
|
||||
const targetDir = path.join(tmpDir, '.qwen');
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
stubHooksIntoTarget(targetDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('re-running after persisted first install does not duplicate hook entries', () => {
|
||||
// First install: get settings and persist to disk (simulating finishInstall)
|
||||
const result1 = install(false, 'qwen');
|
||||
persistSettings(result1.settingsPath, result1.settings);
|
||||
|
||||
// Second install: reads the persisted settings.json — dedup guards apply
|
||||
process.chdir(tmpDir);
|
||||
const result2 = install(false, 'qwen');
|
||||
const s2 = result2.settings;
|
||||
|
||||
for (const event of ['SubagentStop', 'Stop', 'PreCompact']) {
|
||||
const cmds = hooksForEvent(s2, event);
|
||||
assert.strictEqual(cmds.length, 1,
|
||||
`Event ${event} should have exactly 1 hook command after idempotent reinstall; got ${cmds.length}: ${JSON.stringify(cmds)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Suite 4: Uninstall removes the new event registrations ──────────────────
|
||||
|
||||
describe('enh-788: Qwen uninstall removes new hook event entries', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-788-uninstall-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
|
||||
const targetDir = path.join(tmpDir, '.qwen');
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
stubHooksIntoTarget(targetDir);
|
||||
|
||||
// Install and persist to disk so uninstall has a settings.json to clean
|
||||
const result = install(false, 'qwen');
|
||||
persistSettings(result.settingsPath, result.settings);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('settings.json hook entries are removed on uninstall', () => {
|
||||
uninstall(false, 'qwen');
|
||||
const settingsPath = path.join(tmpDir, '.qwen', 'settings.json');
|
||||
if (!fs.existsSync(settingsPath)) return; // file removed entirely is fine
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
for (const event of ['SubagentStop', 'Stop', 'PreCompact']) {
|
||||
const cmds = hooksForEvent(settings, event);
|
||||
assert.strictEqual(cmds.length, 0,
|
||||
`After uninstall, ${event} should have 0 hooks; got: ${JSON.stringify(cmds)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,262 +0,0 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow .md / command .md / SKILL.md files — their text IS what the runtime
|
||||
// loads. Testing emitted text tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Regression guard — enh(#789): elevate CodeBuddy slash-command surface.
|
||||
*
|
||||
* CodeBuddy (Tencent, @tencent-ai/codebuddy-code) reads user-level surfaces
|
||||
* (https://www.codebuddy.ai/docs/cli/slash-commands, /skills):
|
||||
* - commands/gsd-<stem>.md — slash commands shown in the '/' menu
|
||||
* - skills/gsd-<stem>/SKILL.md — model-invocable skills
|
||||
*
|
||||
* Before #789 gsd emitted only skills/. Because CodeBuddy skills default to
|
||||
* user-invocable:true (appear in '/'), emitting a commands/ surface AND leaving
|
||||
* skills user-invocable would duplicate every /gsd-* entry. #789 therefore:
|
||||
* 1. emits commands/gsd-<stem>.md (the '/' surface, peer-consistent with
|
||||
* Cursor #785 and Augment #790),
|
||||
* 2. marks skills user-invocable:false so they become model-invocable
|
||||
* background knowledge and the commands/ surface is the sole '/' surface.
|
||||
*
|
||||
* Subagents are already emitted via the generic agents block + convertClaude
|
||||
* AgentToCodebuddyAgent (~/.codebuddy/agents/), so #789 adds no agents change.
|
||||
*
|
||||
* mcp.json is intentionally NOT written: gsd ships no MCP server, and CodeBuddy's
|
||||
* mcp.json holds an `mcpServers` map of *external* servers to connect to —
|
||||
* there is nothing for gsd to register. Same exclusion as #784/#785/#790.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const {
|
||||
convertClaudeCommandToCodebuddyCommand,
|
||||
convertClaudeCommandToCodebuddySkill,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
const {
|
||||
installRuntimeArtifacts,
|
||||
uninstallRuntimeArtifacts,
|
||||
} = require('../gsd-core/bin/lib/install-engine.cjs');
|
||||
const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs');
|
||||
const { loadSkillsManifest, resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs');
|
||||
|
||||
const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
|
||||
const MANIFEST = loadSkillsManifest(REAL_COMMANDS_DIR);
|
||||
const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST });
|
||||
|
||||
// ─── Layout contract ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('enh-789 — codebuddy layout has commands + skills kinds', () => {
|
||||
test('resolveRuntimeArtifactLayout codebuddy returns 3 kinds (ADR-1235 §1 agents cutover)', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('codebuddy', '/tmp/fake-codebuddy-dir');
|
||||
assert.strictEqual(layout.kinds.length, 3, 'codebuddy must have exactly 3 artifact kinds (commands + skills + agents)');
|
||||
const kindNames = layout.kinds.map(k => k.kind).sort();
|
||||
assert.deepStrictEqual(kindNames, ['agents', 'commands', 'skills']);
|
||||
});
|
||||
|
||||
test('codebuddy commands kind targets commands/ with gsd- prefix', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('codebuddy', '/tmp/fake-codebuddy-dir');
|
||||
const commandsKind = layout.kinds.find(k => k.kind === 'commands');
|
||||
assert.ok(commandsKind, 'must have commands kind');
|
||||
assert.strictEqual(commandsKind.destSubpath, 'commands');
|
||||
assert.strictEqual(commandsKind.prefix, 'gsd-');
|
||||
assert.strictEqual(typeof commandsKind.stage, 'function');
|
||||
});
|
||||
|
||||
test('codebuddy skills kind targets skills/ with gsd- prefix', () => {
|
||||
const layout = resolveRuntimeArtifactLayout('codebuddy', '/tmp/fake-codebuddy-dir');
|
||||
const skillsKind = layout.kinds.find(k => k.kind === 'skills');
|
||||
assert.ok(skillsKind, 'must have skills kind');
|
||||
assert.strictEqual(skillsKind.destSubpath, 'skills');
|
||||
assert.strictEqual(skillsKind.prefix, 'gsd-');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Command converter contract ──────────────────────────────────────────────
|
||||
|
||||
describe('enh-789 — convertClaudeCommandToCodebuddyCommand', () => {
|
||||
const SRC = [
|
||||
'---',
|
||||
'name: gsd:new-project',
|
||||
'description: Initialize a project',
|
||||
'argument-hint: "[name]"',
|
||||
'allowed-tools:',
|
||||
' - Read',
|
||||
'---',
|
||||
'',
|
||||
'Use .claude/skills/ and run /gsd:help. Claude Code reads CLAUDE.md.',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
test('emits a description-only frontmatter (no Claude-specific name: gsd:)', () => {
|
||||
const out = convertClaudeCommandToCodebuddyCommand(SRC, 'gsd-new-project');
|
||||
assert.ok(out.startsWith('---\n'), 'must begin with frontmatter');
|
||||
assert.ok(/^description:/m.test(out), 'must carry a description field');
|
||||
assert.ok(!out.includes('name: gsd:new-project'), 'must drop Claude colon-form name field');
|
||||
});
|
||||
|
||||
test('preserves a present argument-hint (CodeBuddy supports it)', () => {
|
||||
const out = convertClaudeCommandToCodebuddyCommand(SRC, 'gsd-new-project');
|
||||
assert.ok(/^argument-hint:\s*["']?\[name\]["']?\s*$/m.test(out),
|
||||
`argument-hint must be carried through when present in source. Got:\n${out}`);
|
||||
});
|
||||
|
||||
test('converts body Claude-isms to CodeBuddy equivalents', () => {
|
||||
const out = convertClaudeCommandToCodebuddyCommand(SRC, 'gsd-new-project');
|
||||
assert.ok(out.includes('.codebuddy/skills/'), out);
|
||||
assert.ok(out.includes('/gsd-help'), out);
|
||||
assert.ok(out.includes('CODEBUDDY.md'), out);
|
||||
assert.ok(!/\bClaude Code\b/.test(out), 'must rebrand "Claude Code"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('enh-789 — skills marked user-invocable:false', () => {
|
||||
test('convertClaudeCommandToCodebuddySkill emits user-invocable: false', () => {
|
||||
const src = [
|
||||
'---',
|
||||
'name: gsd:help',
|
||||
'description: Show help',
|
||||
'---',
|
||||
'',
|
||||
'# body',
|
||||
'',
|
||||
].join('\n');
|
||||
const out = convertClaudeCommandToCodebuddySkill(src, 'gsd-help');
|
||||
assert.ok(/^user-invocable:\s*false\s*$/m.test(out),
|
||||
`SKILL.md frontmatter must hide skill from '/' menu (user-invocable: false). Got:\n${out}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Install contract ────────────────────────────────────────────────────────
|
||||
|
||||
describe('enh-789 — installRuntimeArtifacts codebuddy emits commands and skills', () => {
|
||||
test('global codebuddy install: commands/gsd-help.md and skills/gsd-help/SKILL.md exist', (t) => {
|
||||
const configDir = createTempDir('gsd-enh789-codebuddy-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE);
|
||||
|
||||
const commandsDir = path.join(configDir, 'commands');
|
||||
assert.ok(fs.existsSync(commandsDir), 'commands/ dir must exist');
|
||||
const cmdFiles = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md'));
|
||||
assert.ok(cmdFiles.length > 0, 'at least one gsd-*.md command file must be installed');
|
||||
assert.ok(fs.existsSync(path.join(commandsDir, 'gsd-help.md')), 'commands/gsd-help.md must exist');
|
||||
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
assert.ok(fs.existsSync(skillsDir), 'skills/ dir must exist');
|
||||
assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-help', 'SKILL.md')), 'skills/gsd-help/SKILL.md must exist');
|
||||
});
|
||||
|
||||
test('installed commands/gsd-help.md is CodeBuddy-compatible (no raw ~/.claude/, rebranded)', (t) => {
|
||||
const configDir = createTempDir('gsd-enh789-content-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE);
|
||||
|
||||
const helpCmd = path.join(configDir, 'commands', 'gsd-help.md');
|
||||
const content = fs.readFileSync(helpCmd, 'utf8');
|
||||
assert.ok(!content.includes('~/.claude/'), 'commands must not contain raw ~/.claude/ refs');
|
||||
assert.ok(content.startsWith('---'), 'commands must carry frontmatter');
|
||||
});
|
||||
|
||||
test('installed skills/gsd-help/SKILL.md is hidden from the / menu', (t) => {
|
||||
const configDir = createTempDir('gsd-enh789-skillhide-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE);
|
||||
|
||||
const skill = fs.readFileSync(path.join(configDir, 'skills', 'gsd-help', 'SKILL.md'), 'utf8');
|
||||
assert.ok(/^user-invocable:\s*false\s*$/m.test(skill),
|
||||
'installed SKILL.md must set user-invocable: false');
|
||||
});
|
||||
|
||||
test('command count matches skill count (profile parity)', (t) => {
|
||||
const configDir = createTempDir('gsd-enh789-parity-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE);
|
||||
|
||||
const cmdCount = fs.readdirSync(path.join(configDir, 'commands'))
|
||||
.filter(f => f.startsWith('gsd-') && f.endsWith('.md')).length;
|
||||
const skillCount = fs.readdirSync(path.join(configDir, 'skills'), { withFileTypes: true })
|
||||
.filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length;
|
||||
assert.strictEqual(cmdCount, skillCount, 'command count must equal skill count for same profile');
|
||||
});
|
||||
|
||||
test('full profile install: no $HOME/.codebuddy or ~/.codebuddy leak in any command', (t) => {
|
||||
// The codebuddy converter rewrites `.claude/` → `.codebuddy/`, so source
|
||||
// refs like `@$HOME/.claude/gsd-core/...` (e.g. plan-review-convergence.md)
|
||||
// must be normalized to the install target — not left as $HOME/.codebuddy.
|
||||
const RESOLVED_FULL = resolveProfile({ modes: ['full'], manifest: MANIFEST });
|
||||
const configDir = createTempDir('gsd-enh789-noleak-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_FULL);
|
||||
|
||||
const commandsDir = path.join(configDir, 'commands');
|
||||
for (const f of fs.readdirSync(commandsDir).filter(n => n.endsWith('.md'))) {
|
||||
const content = fs.readFileSync(path.join(commandsDir, f), 'utf8');
|
||||
assert.ok(!content.includes('$HOME/.codebuddy'), `${f} must not leak $HOME/.codebuddy`);
|
||||
assert.ok(!content.includes('~/.codebuddy'), `${f} must not leak ~/.codebuddy`);
|
||||
assert.ok(!content.includes('.claude/'), `${f} must not retain raw .claude/ refs`);
|
||||
}
|
||||
});
|
||||
|
||||
test('full profile install does NOT mutate source commands/gsd/ files', (t) => {
|
||||
const RESOLVED_FULL = resolveProfile({ modes: ['full'], manifest: MANIFEST });
|
||||
assert.strictEqual(RESOLVED_FULL.skills, '*', 'full profile must have skills === "*"');
|
||||
|
||||
const configDir = createTempDir('gsd-enh789-full-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
const srcHelpPath = path.join(REAL_COMMANDS_DIR, 'help.md');
|
||||
const before = fs.readFileSync(srcHelpPath, 'utf8');
|
||||
|
||||
installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_FULL);
|
||||
|
||||
const after = fs.readFileSync(srcHelpPath, 'utf8');
|
||||
assert.strictEqual(before, after, 'source commands/gsd/help.md must not be mutated by the install');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Uninstall contract ──────────────────────────────────────────────────────
|
||||
|
||||
describe('enh-789 — uninstallRuntimeArtifacts removes codebuddy commands', () => {
|
||||
test('uninstall removes gsd-* commands but preserves user commands', (t) => {
|
||||
const configDir = createTempDir('gsd-enh789-uninstall-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
const commandsDir = path.join(configDir, 'commands');
|
||||
fs.mkdirSync(commandsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(commandsDir, 'gsd-help.md'), '# help\n');
|
||||
fs.writeFileSync(path.join(commandsDir, 'user-custom.md'), '# user\n');
|
||||
|
||||
uninstallRuntimeArtifacts('codebuddy', configDir, 'global');
|
||||
|
||||
assert.ok(!fs.existsSync(path.join(commandsDir, 'gsd-help.md')), 'gsd-help.md must be removed');
|
||||
assert.ok(fs.existsSync(path.join(commandsDir, 'user-custom.md')), 'user-custom.md must be preserved');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── mcp.json exclusion ──────────────────────────────────────────────────────
|
||||
|
||||
describe('enh-789 — mcp.json excluded (gsd ships no MCP server)', () => {
|
||||
test('codebuddy install does not write mcp.json / .mcp.json', (t) => {
|
||||
const configDir = createTempDir('gsd-enh789-mcp-excluded-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE);
|
||||
|
||||
assert.ok(!fs.existsSync(path.join(configDir, 'mcp.json')), 'must not write mcp.json');
|
||||
assert.ok(!fs.existsSync(path.join(configDir, '.mcp.json')), 'must not write .mcp.json');
|
||||
});
|
||||
});
|
||||
@@ -1,425 +0,0 @@
|
||||
// allow-test-rule: integration-test-input
|
||||
// Exercises install() + generateCodexAgentToml() as a black-box by inspecting
|
||||
// produced output files in temp dirs. Source agent .md files are inputs whose
|
||||
// installed transformation is asserted — not inspected for string presence.
|
||||
|
||||
/**
|
||||
* #443 — Effort per-runtime wiring at install time.
|
||||
*
|
||||
* Verifies:
|
||||
* 1. Claude global install injects `effort:` into agent .md frontmatter.
|
||||
* 2. Gemini global install does NOT inject `effort:` (Gemini-safe .md).
|
||||
* 3. Codex inherited-model installs omit `model_reasoning_effort` so model
|
||||
* and effort are not partially pinned (#838).
|
||||
* 4. Config-driven proof: effort.agent_overrides wins over tier defaults
|
||||
* for Claude .md and for Codex .toml when runtime:"codex" pins a model.
|
||||
* 5. Source agents/gsd-planner.md has NO effort: key (injection is
|
||||
* install-only, source stays Gemini-safe).
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const { install } = require('../bin/install.js');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const SOURCE_AGENTS_DIR = path.join(REPO_ROOT, 'agents');
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeTmpDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function readFrontmatter(mdPath) {
|
||||
const content = fs.readFileSync(mdPath, 'utf8');
|
||||
if (!content.startsWith('---')) return '';
|
||||
const end = content.indexOf('---', 3);
|
||||
if (end === -1) return '';
|
||||
return content.substring(3, end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a global install for the given runtime, redirecting its home dir to
|
||||
* tmpHome. Returns the tmpHome for inspection.
|
||||
*
|
||||
* Env-var redirection:
|
||||
* claude → CLAUDE_CONFIG_DIR
|
||||
* gemini → GEMINI_CONFIG_DIR
|
||||
* codex → CODEX_HOME
|
||||
*
|
||||
* HOME is also redirected to an isolated temp dir for the duration of the
|
||||
* install call. This prevents any install.js code that uses os.homedir()
|
||||
* directly (e.g. ~/.cache/gsd update-check deletion, ~/.gsd/defaults.json
|
||||
* reads, stale-SDK npm subprocess writes to ~/.npm) from touching the real
|
||||
* HOME and polluting the test environment for other concurrently-running
|
||||
* test files (e.g. runtime-launcher-parity test (D) checks that
|
||||
* $HOME/.claude/gsd-core/bin/gsd-tools.cjs is absent).
|
||||
*
|
||||
* GSD_SKIP_STALE_SDK_CHECK=1 is set to suppress the `npm ls -g` subprocess
|
||||
* that the installer spawns for global installs — that subprocess is slow,
|
||||
* writes to ~/.npm cache, and is irrelevant to effort-wiring assertions.
|
||||
*
|
||||
* The working directory is set to REPO_ROOT so install() can find the source
|
||||
* agents/. For config-driven tests, place tmpHome inside the project dir
|
||||
* so that readGsdEffectiveEffortConfig(targetDir) can walk up from tmpHome
|
||||
* and find .planning/config.json.
|
||||
*/
|
||||
function runGlobalInstall(runtime, tmpHome) {
|
||||
const envVarMap = {
|
||||
claude: 'CLAUDE_CONFIG_DIR',
|
||||
gemini: 'GEMINI_CONFIG_DIR',
|
||||
codex: 'CODEX_HOME',
|
||||
};
|
||||
const envVar = envVarMap[runtime];
|
||||
if (!envVar) throw new Error(`Unsupported runtime in test: ${runtime}`);
|
||||
|
||||
// Isolate HOME to a fresh temp dir so install.js code that calls
|
||||
// os.homedir() (cache deletion, defaults.json reads, npm subprocess)
|
||||
// never touches the real $HOME/.claude / $HOME/.cache / $HOME/.gsd.
|
||||
const isolatedHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-443-home-'));
|
||||
|
||||
const prev = process.env[envVar];
|
||||
const prevCwd = process.cwd();
|
||||
const prevHome = process.env.HOME;
|
||||
const prevUserProfile = process.env.USERPROFILE;
|
||||
const prevSkipStale = process.env.GSD_SKIP_STALE_SDK_CHECK;
|
||||
|
||||
process.env[envVar] = tmpHome;
|
||||
process.env.HOME = isolatedHome;
|
||||
process.env.USERPROFILE = isolatedHome;
|
||||
process.env.GSD_SKIP_STALE_SDK_CHECK = '1';
|
||||
process.chdir(REPO_ROOT);
|
||||
|
||||
try {
|
||||
install(true, runtime);
|
||||
} finally {
|
||||
process.chdir(prevCwd);
|
||||
if (prev === undefined) delete process.env[envVar];
|
||||
else process.env[envVar] = prev;
|
||||
if (prevHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = prevHome;
|
||||
if (prevUserProfile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = prevUserProfile;
|
||||
if (prevSkipStale === undefined) delete process.env.GSD_SKIP_STALE_SDK_CHECK;
|
||||
else process.env.GSD_SKIP_STALE_SDK_CHECK = prevSkipStale;
|
||||
// Clean up the isolated HOME dir
|
||||
cleanup(isolatedHome);
|
||||
}
|
||||
|
||||
return tmpHome;
|
||||
}
|
||||
|
||||
// ─── Tier default expectations ────────────────────────────────────────────────
|
||||
// light → low, standard → high, heavy → xhigh (catalog defaults)
|
||||
// gsd-planner: heavy → xhigh
|
||||
// gsd-codebase-mapper: light → low
|
||||
// gsd-executor: standard → high
|
||||
|
||||
// ─── describe 1: Claude install injects effort: ───────────────────────────────
|
||||
|
||||
describe('#443 Claude install: effort: injected into frontmatter', () => {
|
||||
let tmpDir;
|
||||
let claudeHome;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = makeTmpDir('gsd-443-claude-');
|
||||
claudeHome = path.join(tmpDir, 'claude-home');
|
||||
fs.mkdirSync(claudeHome, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('gsd-planner.md contains effort: xhigh (heavy tier default)', () => {
|
||||
runGlobalInstall('claude', claudeHome);
|
||||
const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-planner.md'));
|
||||
assert.match(fm, /^effort:\s*xhigh$/m,
|
||||
`gsd-planner frontmatter should have effort: xhigh\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('gsd-codebase-mapper.md contains effort: low (light tier default)', () => {
|
||||
runGlobalInstall('claude', claudeHome);
|
||||
const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-codebase-mapper.md'));
|
||||
assert.match(fm, /^effort:\s*low$/m,
|
||||
`gsd-codebase-mapper frontmatter should have effort: low\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('gsd-executor.md contains effort: high (standard tier default)', () => {
|
||||
runGlobalInstall('claude', claudeHome);
|
||||
const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-executor.md'));
|
||||
assert.match(fm, /^effort:\s*high$/m,
|
||||
`gsd-executor frontmatter should have effort: high\nActual:\n${fm}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── describe 2: Gemini install does NOT inject effort: ──────────────────────
|
||||
|
||||
describe('#443 Gemini install: effort: absent (Gemini-safe)', () => {
|
||||
let tmpDir;
|
||||
let geminiHome;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = makeTmpDir('gsd-443-gemini-');
|
||||
geminiHome = path.join(tmpDir, 'gemini-home');
|
||||
fs.mkdirSync(geminiHome, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('gsd-planner.md does NOT contain effort: (Gemini install)', () => {
|
||||
runGlobalInstall('gemini', geminiHome);
|
||||
const fm = readFrontmatter(path.join(geminiHome, 'agents', 'gsd-planner.md'));
|
||||
assert.doesNotMatch(fm, /^effort:/m,
|
||||
`gsd-planner (Gemini) frontmatter must NOT have effort:\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('gsd-executor.md does NOT contain effort: (Gemini install)', () => {
|
||||
runGlobalInstall('gemini', geminiHome);
|
||||
const fm = readFrontmatter(path.join(geminiHome, 'agents', 'gsd-executor.md'));
|
||||
assert.doesNotMatch(fm, /^effort:/m,
|
||||
`gsd-executor (Gemini) frontmatter must NOT have effort:\nActual:\n${fm}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── describe 3: Codex inherited-model install omits model_reasoning_effort ──
|
||||
|
||||
describe('#838 Codex install: inherited model omits model_reasoning_effort', () => {
|
||||
let tmpDir;
|
||||
let codexHome;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = makeTmpDir('gsd-443-codex-');
|
||||
codexHome = path.join(tmpDir, 'codex-home');
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('gsd-planner.toml omits both model and model_reasoning_effort when model is inherited', () => {
|
||||
runGlobalInstall('codex', codexHome);
|
||||
const tomlContent = fs.readFileSync(
|
||||
path.join(codexHome, 'agents', 'gsd-planner.toml'), 'utf8'
|
||||
);
|
||||
assert.doesNotMatch(tomlContent, /^model\s*=/m,
|
||||
`gsd-planner.toml should omit model when inheriting Codex chat model\nActual:\n${tomlContent.slice(0, 500)}`);
|
||||
assert.doesNotMatch(tomlContent, /^model_reasoning_effort\s*=/m,
|
||||
`gsd-planner.toml should omit model_reasoning_effort when model is inherited\nActual:\n${tomlContent.slice(0, 500)}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── describe 4: Config-driven proof ─────────────────────────────────────────
|
||||
//
|
||||
// The runtime home dir must be INSIDE (or a sibling of) the project root so
|
||||
// that readGsdEffectiveEffortConfig(targetDir) can walk up from the runtime
|
||||
// home and find .planning/config.json. We put .claude/ and .codex/ as siblings
|
||||
// of .planning/ inside the project dir — this is the natural local-install shape.
|
||||
|
||||
describe('#443 Config-driven: effort.agent_overrides drives install-time effort', () => {
|
||||
let tmpDir;
|
||||
let claudeHome;
|
||||
let codexHome;
|
||||
|
||||
beforeEach(() => {
|
||||
// Layout: tmpDir/project/ <-- project root (cwd for install)
|
||||
// .planning/config.json
|
||||
// .claude/ <-- claudeHome (CLAUDE_CONFIG_DIR)
|
||||
// .codex/ <-- codexHome (CODEX_HOME)
|
||||
tmpDir = makeTmpDir('gsd-443-cfg-');
|
||||
const projectDir = path.join(tmpDir, 'project');
|
||||
claudeHome = path.join(projectDir, '.claude');
|
||||
codexHome = path.join(projectDir, '.codex');
|
||||
|
||||
fs.mkdirSync(claudeHome, { recursive: true });
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
fs.mkdirSync(path.join(projectDir, '.planning'), { recursive: true });
|
||||
|
||||
// Write a project config with effort.agent_overrides overriding gsd-planner to 'low'.
|
||||
// runtime:"codex" pins a Codex-native model, so emitting model_reasoning_effort
|
||||
// remains valid under the #838 model/effort coupling rule.
|
||||
const config = {
|
||||
runtime: 'codex',
|
||||
effort: {
|
||||
agent_overrides: {
|
||||
'gsd-planner': 'low',
|
||||
},
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(projectDir, '.planning', 'config.json'),
|
||||
JSON.stringify(config, null, 2)
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('Claude .md gets effort: low when agent_overrides.gsd-planner=low', () => {
|
||||
// projectDir is the cwd for install — chdir handled inside runGlobalInstall.
|
||||
// claudeHome is inside projectDir, so walking up from claudeHome finds .planning/config.json.
|
||||
runGlobalInstall('claude', claudeHome);
|
||||
const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-planner.md'));
|
||||
assert.match(fm, /^effort:\s*low$/m,
|
||||
`gsd-planner should have effort: low from config override\nActual:\n${fm}`);
|
||||
});
|
||||
|
||||
test('Codex .toml gets model_reasoning_effort = "low" when agent_overrides.gsd-planner=low', () => {
|
||||
runGlobalInstall('codex', codexHome);
|
||||
const tomlContent = fs.readFileSync(
|
||||
path.join(codexHome, 'agents', 'gsd-planner.toml'), 'utf8'
|
||||
);
|
||||
assert.match(tomlContent, /^model\s*=\s*"gpt-5.5"$/m,
|
||||
`gsd-planner.toml should pin Codex model when runtime:"codex" is configured\nActual:\n${tomlContent.slice(0, 500)}`);
|
||||
assert.match(tomlContent, /^model_reasoning_effort\s*=\s*"low"$/m,
|
||||
`gsd-planner.toml should have model_reasoning_effort = "low" from config override\nActual:\n${tomlContent.slice(0, 500)}`);
|
||||
});
|
||||
|
||||
test('Codex .toml clamps effort max → xhigh when agent_overrides.gsd-planner=max', () => {
|
||||
const projectDir = path.dirname(codexHome);
|
||||
// Overwrite config with max override
|
||||
const config = {
|
||||
runtime: 'codex',
|
||||
effort: {
|
||||
agent_overrides: {
|
||||
'gsd-planner': 'max',
|
||||
},
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(projectDir, '.planning', 'config.json'),
|
||||
JSON.stringify(config, null, 2)
|
||||
);
|
||||
|
||||
runGlobalInstall('codex', codexHome);
|
||||
const tomlContent = fs.readFileSync(
|
||||
path.join(codexHome, 'agents', 'gsd-planner.toml'), 'utf8'
|
||||
);
|
||||
assert.match(tomlContent, /^model\s*=\s*"gpt-5.5"$/m,
|
||||
`gsd-planner.toml should pin Codex model when runtime:"codex" is configured\nActual:\n${tomlContent.slice(0, 500)}`);
|
||||
// Codex does not support 'max' → clamped to 'xhigh'
|
||||
assert.match(tomlContent, /^model_reasoning_effort\s*=\s*"xhigh"$/m,
|
||||
`gsd-planner.toml should clamp max → xhigh for Codex\nActual:\n${tomlContent.slice(0, 500)}`);
|
||||
assert.doesNotMatch(tomlContent, /model_reasoning_effort\s*=\s*"max"/,
|
||||
'Codex .toml must never contain model_reasoning_effort = "max"');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── describe 5b: Invalid effort tokens fall through (Codex adversarial finding #2) ─
|
||||
//
|
||||
// These tests FAIL before the fix: resolveInstallTimeEffort returns the raw
|
||||
// invalid string without validating it against VALID_EFFORTS.
|
||||
|
||||
describe('#443 resolveInstallTimeEffort: invalid tokens fall through to valid effort', () => {
|
||||
let tmpDir;
|
||||
let claudeHome;
|
||||
let codexHome;
|
||||
|
||||
beforeEach(() => {
|
||||
// Layout: tmpDir/project/ <-- project root
|
||||
// .planning/config.json
|
||||
// .claude/ <-- claudeHome
|
||||
// .codex/ <-- codexHome
|
||||
tmpDir = makeTmpDir('gsd-443-invalid-effort-');
|
||||
const projectDir = path.join(tmpDir, 'project');
|
||||
claudeHome = path.join(projectDir, '.claude');
|
||||
codexHome = path.join(projectDir, '.codex');
|
||||
|
||||
fs.mkdirSync(claudeHome, { recursive: true });
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
fs.mkdirSync(path.join(projectDir, '.planning'), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
function writeProjectConfig(config) {
|
||||
const projectDir = path.dirname(claudeHome);
|
||||
fs.writeFileSync(
|
||||
path.join(projectDir, '.planning', 'config.json'),
|
||||
JSON.stringify(config, null, 2)
|
||||
);
|
||||
}
|
||||
|
||||
const VALID_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
|
||||
|
||||
test('effort.default="ultra" (invalid) -> Claude .md effort: is a VALID value (falls through to high)', () => {
|
||||
// BUG before fix: resolveInstallTimeEffort returns "ultra" verbatim
|
||||
writeProjectConfig({ effort: { default: 'ultra' } });
|
||||
runGlobalInstall('claude', claudeHome);
|
||||
const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-planner.md'));
|
||||
const match = fm.match(/^effort:\s*(\S+)$/m);
|
||||
assert.ok(match, `effort: must be present in frontmatter\nActual:\n${fm}`);
|
||||
assert.ok(VALID_EFFORTS.includes(match[1]),
|
||||
`effort: must be a VALID effort string, got: "${match[1]}"\nActual frontmatter:\n${fm}`);
|
||||
});
|
||||
|
||||
test('effort.agent_overrides.gsd-planner="bogus" (invalid) with valid default -> falls through to valid default', () => {
|
||||
// BUG before fix: "bogus" is returned and written verbatim
|
||||
writeProjectConfig({
|
||||
effort: {
|
||||
agent_overrides: { 'gsd-planner': 'bogus' },
|
||||
default: 'medium',
|
||||
},
|
||||
});
|
||||
runGlobalInstall('claude', claudeHome);
|
||||
const fm = readFrontmatter(path.join(claudeHome, 'agents', 'gsd-planner.md'));
|
||||
const match = fm.match(/^effort:\s*(\S+)$/m);
|
||||
assert.ok(match, `effort: must be present in frontmatter\nActual:\n${fm}`);
|
||||
assert.ok(VALID_EFFORTS.includes(match[1]),
|
||||
`effort: must be a VALID effort string, got: "${match[1]}"\nActual frontmatter:\n${fm}`);
|
||||
// Falls through invalid "bogus" -> valid tier default or "medium" default
|
||||
// "medium" is valid, so it should appear (or tier default if medium is invalid, but medium is valid)
|
||||
});
|
||||
|
||||
test('effort.default="ultra" (invalid) + runtime:"codex" -> Codex .toml model_reasoning_effort is VALID', () => {
|
||||
// BUG before fix: "ultra" written into .toml verbatim
|
||||
writeProjectConfig({ runtime: 'codex', effort: { default: 'ultra' } });
|
||||
runGlobalInstall('codex', codexHome);
|
||||
const tomlContent = fs.readFileSync(
|
||||
path.join(codexHome, 'agents', 'gsd-planner.toml'), 'utf8'
|
||||
);
|
||||
assert.match(tomlContent, /^model\s*=\s*"gpt-5.5"$/m,
|
||||
`gsd-planner.toml should pin Codex model when runtime:"codex" is configured\nActual:\n${tomlContent.slice(0, 500)}`);
|
||||
const match = tomlContent.match(/^model_reasoning_effort\s*=\s*"([^"]+)"/m);
|
||||
assert.ok(match, `model_reasoning_effort must be present in .toml\nActual:\n${tomlContent.slice(0, 500)}`);
|
||||
assert.ok(VALID_EFFORTS.includes(match[1]),
|
||||
`model_reasoning_effort must be VALID, got: "${match[1]}"\nActual:\n${tomlContent.slice(0, 500)}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── describe 5: Source stays clean ──────────────────────────────────────────
|
||||
|
||||
describe('#443 Source purity: agents/gsd-planner.md has no effort: key', () => {
|
||||
test('source agents/gsd-planner.md frontmatter does not contain effort:', () => {
|
||||
const fm = readFrontmatter(path.join(SOURCE_AGENTS_DIR, 'gsd-planner.md'));
|
||||
assert.doesNotMatch(fm, /^effort:/m,
|
||||
`Source agents/gsd-planner.md must NOT contain effort: (injection is install-only)`);
|
||||
});
|
||||
|
||||
test('source agents/gsd-executor.md frontmatter does not contain effort:', () => {
|
||||
const fm = readFrontmatter(path.join(SOURCE_AGENTS_DIR, 'gsd-executor.md'));
|
||||
assert.doesNotMatch(fm, /^effort:/m,
|
||||
`Source agents/gsd-executor.md must NOT contain effort: (injection is install-only)`);
|
||||
});
|
||||
|
||||
test('source agents/gsd-codebase-mapper.md frontmatter does not contain effort:', () => {
|
||||
const fm = readFrontmatter(path.join(SOURCE_AGENTS_DIR, 'gsd-codebase-mapper.md'));
|
||||
assert.doesNotMatch(fm, /^effort:/m,
|
||||
`Source agents/gsd-codebase-mapper.md must NOT contain effort: (injection is install-only)`);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -875,3 +875,291 @@ describe('#1004 regression: installer does not duplicate managed hooks when regi
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-1924-preserve-user-artifacts.test.cjs — consolidation epic #1969 (B1 #1970)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-1924-preserve-user-artifacts (consolidation epic #1969 B1 #1970)", () => {
|
||||
// allow-test-rule: source-text-is-the-product (see #1924)
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Regression tests for bug #1924: gsd-update silently deletes user-generated files
|
||||
*
|
||||
* Running the installer (gsd-update / re-install) must not delete:
|
||||
* - gsd-core/USER-PROFILE.md (created by /gsd-profile-user)
|
||||
* - commands/gsd/dev-preferences.md (created by /gsd-profile-user)
|
||||
*
|
||||
* Root cause:
|
||||
* 1. copyWithPathReplacement() calls fs.rmSync(destDir, {recursive:true}) before
|
||||
* copying — no preserve allowlist. This wipes USER-PROFILE.md.
|
||||
* 2. ~line 5211 explicitly rmSync's commands/gsd/ during global install legacy
|
||||
* cleanup — no preserve. This wipes dev-preferences.md.
|
||||
*
|
||||
* Fix requirement:
|
||||
* - install() must preserve USER-PROFILE.md across the gsd-core/ wipe
|
||||
* - install() must preserve dev-preferences.md across the commands/gsd/ wipe
|
||||
*
|
||||
* Closes: #1924
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, beforeEach, afterEach, before } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
||||
|
||||
// ─── Ensure hooks/dist/ is populated before any install test ─────────────────
|
||||
|
||||
before(() => {
|
||||
execFileSync(process.execPath, [BUILD_SCRIPT], {
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function cleanup(dir) {
|
||||
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- local cleanup() helper wrapping rmSync; cannot use imported cleanup() without naming collision
|
||||
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the installer with CLAUDE_CONFIG_DIR redirected to a temp directory.
|
||||
* Explicitly removes GSD_TEST_MODE so the subprocess actually runs the installer
|
||||
* (not just the export block). Uses --yes to suppress interactive prompts.
|
||||
*/
|
||||
function runInstaller(configDir) {
|
||||
const env = { ...process.env, CLAUDE_CONFIG_DIR: configDir };
|
||||
delete env.GSD_TEST_MODE;
|
||||
// --no-sdk: this test covers user-artifact preservation only; skip SDK
|
||||
// build (covered by install-smoke.yml) to keep the test deterministic.
|
||||
execFileSync(process.execPath, [INSTALL_SCRIPT, '--claude', '--global', '--yes', '--no-sdk'], {
|
||||
encoding: 'utf-8',
|
||||
stdio: 'pipe',
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Test 1: USER-PROFILE.md is preserved across re-install ─────────────────
|
||||
|
||||
describe('#1924: USER-PROFILE.md preserved across re-install (global Claude)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-1924-userprofile-');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('USER-PROFILE.md exists after initial install + user creation', () => {
|
||||
runInstaller(tmpDir);
|
||||
|
||||
// Simulate /gsd-profile-user creating USER-PROFILE.md inside gsd-core/
|
||||
const profilePath = path.join(tmpDir, 'gsd-core', 'USER-PROFILE.md');
|
||||
fs.writeFileSync(profilePath, '# My Profile\n\nCustom user content.\n');
|
||||
|
||||
assert.ok(
|
||||
fs.existsSync(profilePath),
|
||||
'USER-PROFILE.md should exist after being created by /gsd-profile-user'
|
||||
);
|
||||
});
|
||||
|
||||
test('USER-PROFILE.md is preserved after re-install', () => {
|
||||
// First install
|
||||
runInstaller(tmpDir);
|
||||
|
||||
// User runs /gsd-profile-user, creating USER-PROFILE.md
|
||||
const profilePath = path.join(tmpDir, 'gsd-core', 'USER-PROFILE.md');
|
||||
const originalContent = '# My Profile\n\nThis is my custom user profile content.\n';
|
||||
fs.writeFileSync(profilePath, originalContent);
|
||||
|
||||
// Re-run installer (simulating gsd-update)
|
||||
runInstaller(tmpDir);
|
||||
|
||||
assert.ok(
|
||||
fs.existsSync(profilePath),
|
||||
'USER-PROFILE.md must survive re-install — gsd-update must not delete user-generated profiles'
|
||||
);
|
||||
|
||||
const afterContent = fs.readFileSync(profilePath, 'utf8');
|
||||
assert.strictEqual(
|
||||
afterContent,
|
||||
originalContent,
|
||||
'USER-PROFILE.md content must be identical after re-install'
|
||||
);
|
||||
});
|
||||
|
||||
test('USER-PROFILE.md is preserved even when gsd-core/ is wiped and recreated', () => {
|
||||
runInstaller(tmpDir);
|
||||
|
||||
const gsdDir = path.join(tmpDir, 'gsd-core');
|
||||
const profilePath = path.join(gsdDir, 'USER-PROFILE.md');
|
||||
|
||||
// Confirm gsd-core/ was created by install
|
||||
assert.ok(fs.existsSync(gsdDir), 'gsd-core/ must exist after install');
|
||||
|
||||
// Write profile
|
||||
fs.writeFileSync(profilePath, '# Profile\n\nMy coding style preferences.\n');
|
||||
|
||||
// Re-install
|
||||
runInstaller(tmpDir);
|
||||
|
||||
// gsd-core/ must still exist AND profile must be intact
|
||||
assert.ok(fs.existsSync(gsdDir), 'gsd-core/ must still exist after re-install');
|
||||
assert.ok(
|
||||
fs.existsSync(profilePath),
|
||||
'USER-PROFILE.md must still exist after gsd-core/ was wiped and recreated'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test 2: dev-preferences.md is preserved across re-install ───────────────
|
||||
|
||||
describe('#1924: dev-preferences.md preserved across re-install (global Claude)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-1924-devprefs-');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('dev-preferences.md is preserved when commands/gsd/ is cleaned up during re-install', () => {
|
||||
// First install (creates skills/ structure for global Claude)
|
||||
runInstaller(tmpDir);
|
||||
|
||||
// User runs /gsd-profile-user — it creates dev-preferences.md in commands/gsd/
|
||||
const commandsGsdDir = path.join(tmpDir, 'commands', 'gsd');
|
||||
fs.mkdirSync(commandsGsdDir, { recursive: true });
|
||||
const devPrefsPath = path.join(commandsGsdDir, 'dev-preferences.md');
|
||||
const originalContent = '# Dev Preferences\n\nI prefer TDD. I like short functions.\n';
|
||||
fs.writeFileSync(devPrefsPath, originalContent);
|
||||
|
||||
// Re-run installer (simulating gsd-update).
|
||||
// In the layout-driven path (B2), legacy commands/gsd/ is removed and
|
||||
// dev-preferences.md is migrated to skills/gsd-dev-preferences/SKILL.md (#2973).
|
||||
runInstaller(tmpDir);
|
||||
|
||||
// Content is migrated to the new canonical skills location (#2973).
|
||||
// The old commands/gsd/ path is cleaned up; the skill file carries the content.
|
||||
const devPrefSkillPath = path.join(tmpDir, 'skills', 'gsd-dev-preferences', 'SKILL.md');
|
||||
assert.ok(
|
||||
fs.existsSync(devPrefSkillPath),
|
||||
'dev-preferences.md must be migrated to skills/gsd-dev-preferences/SKILL.md — gsd-update legacy cleanup must not silently drop user-generated content'
|
||||
);
|
||||
|
||||
const afterContent = fs.readFileSync(devPrefSkillPath, 'utf8');
|
||||
assert.strictEqual(
|
||||
afterContent,
|
||||
originalContent,
|
||||
'migrated dev-preferences content must be identical to the original'
|
||||
);
|
||||
});
|
||||
|
||||
test('legacy non-user GSD commands are still cleaned up during re-install', () => {
|
||||
// First install
|
||||
runInstaller(tmpDir);
|
||||
|
||||
// Simulate a legacy GSD command file being left in commands/gsd/
|
||||
const commandsGsdDir = path.join(tmpDir, 'commands', 'gsd');
|
||||
fs.mkdirSync(commandsGsdDir, { recursive: true });
|
||||
const legacyFile = path.join(commandsGsdDir, 'next.md');
|
||||
fs.writeFileSync(legacyFile, '---\nname: gsd:next\n---\n\nLegacy content.');
|
||||
|
||||
// But dev-preferences.md is also there (user-generated)
|
||||
const devPrefsContent = '# Dev Preferences\n\nMy preferences.\n';
|
||||
const devPrefsPath = path.join(commandsGsdDir, 'dev-preferences.md');
|
||||
fs.writeFileSync(devPrefsPath, devPrefsContent);
|
||||
|
||||
// Re-install
|
||||
runInstaller(tmpDir);
|
||||
|
||||
// In the layout-driven path (B2), commands/gsd/ is fully removed but
|
||||
// dev-preferences.md content is migrated to the new canonical skill location.
|
||||
const devPrefSkillPath = path.join(tmpDir, 'skills', 'gsd-dev-preferences', 'SKILL.md');
|
||||
assert.ok(
|
||||
fs.existsSync(devPrefSkillPath),
|
||||
'dev-preferences.md content must be migrated to skills/gsd-dev-preferences/SKILL.md'
|
||||
);
|
||||
|
||||
// The legacy GSD command (next.md) is NOT user-generated, must be removed
|
||||
// (it would exist only as a skill now in skills/gsd-next/SKILL.md)
|
||||
assert.ok(
|
||||
!fs.existsSync(legacyFile),
|
||||
'legacy GSD command next.md in commands/gsd/ must be removed during cleanup'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test 3: profile-user.md backup path is outside gsd-core/ ───────────
|
||||
|
||||
describe('#1924: profile-user.md backup path must be outside gsd-core/', () => {
|
||||
test('profile-user.md backup uses ~/.claude/USER-PROFILE.backup.md not ~/.claude/gsd-core/USER-PROFILE.backup.md', () => {
|
||||
const workflowPath = path.join(
|
||||
__dirname, '..', 'gsd-core', 'workflows', 'profile-user.md'
|
||||
);
|
||||
const content = fs.readFileSync(workflowPath, 'utf8');
|
||||
|
||||
// The backup must NOT be inside gsd-core/ because that directory is wiped on update
|
||||
assert.ok(
|
||||
!content.includes('gsd-core/USER-PROFILE.backup.md'),
|
||||
'backup path must NOT be inside gsd-core/ — that directory is wiped on gsd-update'
|
||||
);
|
||||
|
||||
// The backup should be at ~/.claude/USER-PROFILE.backup.md (outside gsd-core/)
|
||||
assert.ok(
|
||||
content.includes('USER-PROFILE.backup.md') &&
|
||||
!content.includes('/gsd-core/USER-PROFILE.backup.md'),
|
||||
'backup path must be outside gsd-core/ (e.g. ~/.claude/USER-PROFILE.backup.md)'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Test 4: preserveUserArtifacts helper exported from install.js ────────────
|
||||
|
||||
describe('#1924: preserveUserArtifacts helper exists in install.js', () => {
|
||||
test('install.js exports preserveUserArtifacts function', () => {
|
||||
// Set GSD_TEST_MODE so require() reaches the module.exports block
|
||||
const origMode = process.env.GSD_TEST_MODE;
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
let mod;
|
||||
try {
|
||||
mod = require(INSTALL_SCRIPT);
|
||||
} finally {
|
||||
if (origMode === undefined) {
|
||||
delete process.env.GSD_TEST_MODE;
|
||||
} else {
|
||||
process.env.GSD_TEST_MODE = origMode;
|
||||
}
|
||||
}
|
||||
|
||||
assert.strictEqual(
|
||||
typeof mod.preserveUserArtifacts,
|
||||
'function',
|
||||
'install.js must export preserveUserArtifacts helper for testability'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -383,3 +383,571 @@ describe('_copyStaged write-confinement', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-2998-pristine-dir-populated.test.cjs — consolidation epic #1969 (B1 #1970)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-2998-pristine-dir-populated (consolidation epic #1969 B1 #1970)", () => {
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Bug #2998: gsd-pristine/ snapshot is documented but never populated by
|
||||
* the installer. saveLocalPatches declared a pristineDir variable and
|
||||
* promised "saves pristine copies (from manifest) to gsd-pristine/ to
|
||||
* enable three-way merge during reapply-patches" -- but no code ever
|
||||
* wrote to that directory. Effect: the /gsd-reapply-patches Step 5
|
||||
* verifier (#2972) silently degrades to its over-broad fallback heuristic
|
||||
* ("every significant backup line"), exactly the silent-success-on-lost-
|
||||
* content failure mode #2969 was designed to prevent.
|
||||
*
|
||||
* Fix: new populatePristineDir({...}) helper runs the install transform
|
||||
* pipeline (copyWithPathReplacement) into a tmp staging dir, then copies
|
||||
* out the modified-file paths into gsd-pristine/. saveLocalPatches now
|
||||
* accepts a pristineCtx and calls the helper when local patches are
|
||||
* detected.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const INSTALL = require(path.join(ROOT, 'bin', 'install.js'));
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
function sha256(content) {
|
||||
return crypto.createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
describe('Bug #2998: populatePristineDir is exported and writes pristine for modified files', () => {
|
||||
test('exported as a function', () => {
|
||||
assert.equal(typeof INSTALL.populatePristineDir, 'function',
|
||||
'expected populatePristineDir in install.js exports (#2998)');
|
||||
});
|
||||
|
||||
test('returns 0 when no files are modified (no-op)', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-'));
|
||||
try {
|
||||
const written = INSTALL.populatePristineDir({
|
||||
packageSrc: ROOT,
|
||||
pristineDir: path.join(tmp, 'gsd-pristine'),
|
||||
modified: [],
|
||||
runtime: 'claude',
|
||||
pathPrefix: '$HOME/.claude/',
|
||||
isGlobal: true,
|
||||
});
|
||||
assert.equal(written, 0);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('writes one pristine file per modified path that exists in source', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-'));
|
||||
const pristineDir = path.join(tmp, 'gsd-pristine');
|
||||
try {
|
||||
// Pick a real installed-side relPath from the package source. The
|
||||
// install transforms map source `gsd-core/<rel>` to installed
|
||||
// `gsd-core/<rel>` for skills-aware runtimes (like claude),
|
||||
// so the relPath is the same on both sides.
|
||||
const candidate = path.join('gsd-core', 'workflows', 'reapply-patches.md');
|
||||
const sourcePath = path.join(ROOT, candidate);
|
||||
assert.equal(fs.existsSync(sourcePath), true,
|
||||
`precondition: source file exists at ${candidate}`);
|
||||
const written = INSTALL.populatePristineDir({
|
||||
packageSrc: ROOT,
|
||||
pristineDir,
|
||||
modified: [candidate],
|
||||
runtime: 'claude',
|
||||
pathPrefix: '$HOME/.claude/',
|
||||
isGlobal: true,
|
||||
});
|
||||
assert.equal(written, 1, 'expected exactly one pristine file written');
|
||||
const out = path.join(pristineDir, candidate);
|
||||
assert.equal(fs.existsSync(out), true, `expected pristine file at ${out}`);
|
||||
// The pristine content should be the transformed version (not raw source):
|
||||
// copyWithPathReplacement substitutes ~/.claude/ for the runtime path prefix.
|
||||
// For claude+global, the prefix is $HOME/.claude/ which equals the original,
|
||||
// so the transform is effectively identity here. We assert the content is a
|
||||
// non-empty markdown file rather than asserting on transform specifics.
|
||||
const content = fs.readFileSync(out, 'utf-8');
|
||||
assert.ok(content.length > 0, 'pristine file should be non-empty');
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('skips paths not present in source (does not corrupt pristine with stale data)', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-'));
|
||||
const pristineDir = path.join(tmp, 'gsd-pristine');
|
||||
try {
|
||||
const written = INSTALL.populatePristineDir({
|
||||
packageSrc: ROOT,
|
||||
pristineDir,
|
||||
modified: ['gsd-core/this-path-does-not-exist.md'],
|
||||
runtime: 'claude',
|
||||
pathPrefix: '$HOME/.claude/',
|
||||
isGlobal: true,
|
||||
});
|
||||
assert.equal(written, 0, 'expected zero pristine files for non-existent source paths');
|
||||
const out = path.join(pristineDir, 'gsd-core/this-path-does-not-exist.md');
|
||||
assert.equal(fs.existsSync(out), false, 'pristine should not contain ghost paths');
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('pristine files have stable content (transformations are deterministic)', () => {
|
||||
// Determinism is what makes the verifier's hash check meaningful:
|
||||
// backup-meta.json records pristine_hashes computed at this same step,
|
||||
// so re-running with the same inputs must yield byte-identical files.
|
||||
const tmp1 = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-d1-'));
|
||||
const tmp2 = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-d2-'));
|
||||
try {
|
||||
const candidate = path.join('gsd-core', 'workflows', 'reapply-patches.md');
|
||||
const ctx = {
|
||||
packageSrc: ROOT,
|
||||
modified: [candidate],
|
||||
runtime: 'claude',
|
||||
pathPrefix: '$HOME/.claude/',
|
||||
isGlobal: true,
|
||||
};
|
||||
INSTALL.populatePristineDir(Object.assign({ pristineDir: path.join(tmp1, 'gsd-pristine') }, ctx));
|
||||
INSTALL.populatePristineDir(Object.assign({ pristineDir: path.join(tmp2, 'gsd-pristine') }, ctx));
|
||||
const a = fs.readFileSync(path.join(tmp1, 'gsd-pristine', candidate));
|
||||
const b = fs.readFileSync(path.join(tmp2, 'gsd-pristine', candidate));
|
||||
assert.equal(sha256(a), sha256(b), 'two runs of the same inputs must yield identical pristine content');
|
||||
} finally {
|
||||
cleanup(tmp1);
|
||||
cleanup(tmp2);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #3004 CR follow-up: multi-root pristine expansion ─────────────────────
|
||||
|
||||
describe('Bug #2998 (#3004 CR): pristine expansion covers every manifest install root', () => {
|
||||
test('paths under agents/ are staged via copyWithPathReplacement, not silently skipped', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-multi-'));
|
||||
const pristineDir = path.join(tmp, 'gsd-pristine');
|
||||
try {
|
||||
const candidate = path.join('agents', 'gsd-planner.md');
|
||||
const sourcePath = path.join(ROOT, candidate);
|
||||
assert.equal(fs.existsSync(sourcePath), true,
|
||||
`precondition: source file exists at ${candidate}`);
|
||||
const written = INSTALL.populatePristineDir({
|
||||
packageSrc: ROOT,
|
||||
pristineDir,
|
||||
modified: [candidate],
|
||||
runtime: 'claude',
|
||||
pathPrefix: '$HOME/.claude/',
|
||||
isGlobal: true,
|
||||
});
|
||||
assert.equal(written, 1, 'expected agents/ path to be staged and copied to pristine');
|
||||
assert.equal(fs.existsSync(path.join(pristineDir, candidate)), true);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('a mix of gsd-core/ and agents/ paths in modified list are all staged', () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2998-mix-'));
|
||||
const pristineDir = path.join(tmp, 'gsd-pristine');
|
||||
try {
|
||||
const a = path.join('gsd-core', 'workflows', 'reapply-patches.md');
|
||||
const b = path.join('agents', 'gsd-planner.md');
|
||||
assert.equal(fs.existsSync(path.join(ROOT, a)), true);
|
||||
assert.equal(fs.existsSync(path.join(ROOT, b)), true);
|
||||
const written = INSTALL.populatePristineDir({
|
||||
packageSrc: ROOT,
|
||||
pristineDir,
|
||||
modified: [a, b],
|
||||
runtime: 'claude',
|
||||
pathPrefix: '$HOME/.claude/',
|
||||
isGlobal: true,
|
||||
});
|
||||
assert.equal(written, 2, 'expected both top-level dirs to be staged');
|
||||
assert.equal(fs.existsSync(path.join(pristineDir, a)), true);
|
||||
assert.equal(fs.existsSync(path.join(pristineDir, b)), true);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bug #2998: saveLocalPatches no longer leaves the pristineDir variable unused', () => {
|
||||
test('saveLocalPatches accepts a pristineCtx and exposes the helper for direct testing', () => {
|
||||
// Structural assertion: the function exists with the new signature shape.
|
||||
// Behavioral end-to-end is covered by the populatePristineDir tests above
|
||||
// (that helper is what saveLocalPatches calls internally).
|
||||
assert.equal(typeof INSTALL.populatePristineDir, 'function');
|
||||
// The signature for saveLocalPatches isn't exported, but the helper IS,
|
||||
// and it's the unit of behavior the bug is about. Asserting on the helper
|
||||
// is the structural-IR equivalent of the no-source-grep convention.
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-3407-pristine-stale-content.test.cjs — consolidation epic #1969 (B1 #1970)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-3407-pristine-stale-content (consolidation epic #1969 B1 #1970)", () => {
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Bug #3407: Installer leaves stale content in gsd-pristine/
|
||||
*
|
||||
* Root cause: populatePristineDir() in saveLocalPatches() snapshots from
|
||||
* pristineCtx.packageSrc — the NEWLY-downloaded release tree — and writes
|
||||
* those bytes into gsd-pristine/. For files changed between the old and new
|
||||
* release, this writes the NEW bytes into the pristine baseline instead of
|
||||
* the OLD bytes. The three-way-diff verifier then classifies upstream-changed
|
||||
* lines as user-added → Step 5a gate fails with false FAIL_USER_LINES_MISSING.
|
||||
*
|
||||
* The #3657 fix (OK_PRISTINE_DRIFT_DETECTED) was a symptom workaround: the
|
||||
* verifier detects hash mismatch (backup-meta.json records old-release hash
|
||||
* but gsd-pristine/ has new-release bytes) and skips to over-broad mode
|
||||
* instead of false-failing. The root-cause stale write was never fixed.
|
||||
*
|
||||
* Fix: when a correctly-populated gsd-pristine/ already exists from the
|
||||
* previous install (i.e., the file's sha256 matches the originalHash recorded
|
||||
* in the manifest), preserve it — do NOT wipe and re-populate from the new
|
||||
* release source. This ensures gsd-pristine/ holds old-release bytes even
|
||||
* after an upgrade where the file content changed upstream.
|
||||
*
|
||||
* Regression contract (byte-comparison):
|
||||
* After saveLocalPatches() is called with a user-modified file whose
|
||||
* gsd-pristine/ entry was correctly set by the previous install, the
|
||||
* gsd-pristine/ file MUST still contain the old-release bytes, not the
|
||||
* new-release bytes supplied in pristineCtx.packageSrc.
|
||||
*
|
||||
* Closes: #3407
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const INSTALL = require(path.join(ROOT, 'bin', 'install.js'));
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const MANIFEST_NAME = 'gsd-file-manifest.json';
|
||||
const PATCHES_DIR_NAME = 'gsd-local-patches';
|
||||
|
||||
function sha256(content) {
|
||||
return crypto.createHash('sha256').update(content instanceof Buffer ? content : Buffer.from(content)).digest('hex');
|
||||
}
|
||||
|
||||
// ─── Bug #3407: gsd-pristine/ must preserve OLD-release bytes across upgrade ──
|
||||
|
||||
describe('Bug #3407: saveLocalPatches preserves old-release pristine across upgrade', () => {
|
||||
let tmpDir;
|
||||
let configDir;
|
||||
let fakeSrcDir;
|
||||
|
||||
beforeEach((t) => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3407-'));
|
||||
configDir = path.join(tmpDir, 'config');
|
||||
fakeSrcDir = path.join(tmpDir, 'new-release-src');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.mkdirSync(fakeSrcDir, { recursive: true });
|
||||
t.after(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Core regression test.
|
||||
*
|
||||
* Timeline:
|
||||
* Install v1: file content = OLD_RELEASE_CONTENT, gsd-pristine/ROOT_FILE
|
||||
* = OLD_RELEASE_CONTENT (correctly set by previous install),
|
||||
* manifest hash = sha256(OLD_RELEASE_CONTENT)
|
||||
* User edits: configDir/ROOT_FILE = USER_MODIFIED_CONTENT
|
||||
* Upgrade v2: pristineCtx.packageSrc has NEW_RELEASE_CONTENT for ROOT_FILE
|
||||
* saveLocalPatches is called before the wipe.
|
||||
*
|
||||
* Expected AFTER fix: gsd-pristine/ROOT_FILE still == OLD_RELEASE_CONTENT
|
||||
* Actual BEFORE fix: gsd-pristine/ROOT_FILE == NEW_RELEASE_CONTENT (stale)
|
||||
*/
|
||||
test('gsd-pristine/ retains old-release bytes when upgrading a user-modified file', () => {
|
||||
const OLD_RELEASE_CONTENT = '# Old Release Content\nThis is v1 pristine.\n';
|
||||
const NEW_RELEASE_CONTENT = '# New Release Content\nThis is v2 — upstream changed this line.\n';
|
||||
const USER_MODIFIED_CONTENT = '# Old Release Content\nThis is v1 pristine.\n## User addition\nUser customization here.\n';
|
||||
|
||||
const oldHash = sha256(OLD_RELEASE_CONTENT);
|
||||
|
||||
// Simulate a root-level installed file. Root-level files in the manifest
|
||||
// are denoted without a subdirectory (slash-free relPath).
|
||||
const relPath = 'test-root-file.md';
|
||||
|
||||
// Set up configDir: user-modified installed file + manifest recording old hash
|
||||
fs.writeFileSync(path.join(configDir, relPath), USER_MODIFIED_CONTENT);
|
||||
fs.writeFileSync(
|
||||
path.join(configDir, MANIFEST_NAME),
|
||||
JSON.stringify({ version: '1.0.0', files: { [relPath]: oldHash } }, null, 2)
|
||||
);
|
||||
|
||||
// Set up fakeSrcDir (new release): the file has NEW content
|
||||
fs.writeFileSync(path.join(fakeSrcDir, relPath), NEW_RELEASE_CONTENT);
|
||||
|
||||
// Set up gsd-pristine/ with OLD content (as correctly populated by previous install)
|
||||
const pristineDir = path.join(configDir, 'gsd-pristine');
|
||||
fs.mkdirSync(pristineDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(pristineDir, relPath), OLD_RELEASE_CONTENT);
|
||||
|
||||
// Call saveLocalPatches with the new release as packageSrc (the buggy scenario)
|
||||
INSTALL.saveLocalPatches(configDir, {
|
||||
packageSrc: fakeSrcDir,
|
||||
runtime: 'claude',
|
||||
pathPrefix: '$HOME/.claude/',
|
||||
isGlobal: true,
|
||||
});
|
||||
|
||||
// Assert: gsd-pristine/ must still contain OLD-release bytes
|
||||
const pristineFile = path.join(pristineDir, relPath);
|
||||
assert.ok(
|
||||
fs.existsSync(pristineFile),
|
||||
`gsd-pristine/${relPath} must exist after saveLocalPatches`
|
||||
);
|
||||
|
||||
const actualPristineContent = fs.readFileSync(pristineFile, 'utf8');
|
||||
assert.equal(
|
||||
sha256(actualPristineContent),
|
||||
oldHash,
|
||||
[
|
||||
`gsd-pristine/${relPath} must contain OLD-release bytes (sha256=${oldHash.slice(0, 12)}…)`,
|
||||
`but got sha256=${sha256(actualPristineContent).slice(0, 12)}…`,
|
||||
`(If equal to sha256(NEW_RELEASE_CONTENT)=${sha256(NEW_RELEASE_CONTENT).slice(0, 12)}… then #3407 is NOT fixed)`,
|
||||
].join(' ')
|
||||
);
|
||||
|
||||
// Secondary: confirm backup-meta records the old hash (not new)
|
||||
const backupMeta = JSON.parse(
|
||||
fs.readFileSync(path.join(configDir, PATCHES_DIR_NAME, 'backup-meta.json'), 'utf8')
|
||||
);
|
||||
assert.ok(
|
||||
Object.prototype.hasOwnProperty.call(backupMeta.pristine_hashes, relPath),
|
||||
'backup-meta.json must record pristine_hash for modified file'
|
||||
);
|
||||
assert.equal(
|
||||
backupMeta.pristine_hashes[relPath],
|
||||
oldHash,
|
||||
'backup-meta.json pristine_hash must equal old-release hash (not new-release hash)'
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Regression test for Codex finding: when gsd-pristine/ entry is absent
|
||||
* (e.g., post-buggy-run deletion or first upgrade without prior pristine)
|
||||
* but the file is UNCHANGED between old and new release, the hash-validated
|
||||
* regeneration path must restore the pristine entry using new-release source.
|
||||
*
|
||||
* When sha256(newReleaseBytesForFile) === originalHash, the file is identical
|
||||
* between releases — new-release generated bytes ARE the old-release pristine
|
||||
* and may be safely promoted.
|
||||
*
|
||||
* Previously (before the regeneration path was added): missing entries were
|
||||
* left absent unconditionally, causing permanent over-broad fallback even
|
||||
* when the file was unchanged upstream.
|
||||
*/
|
||||
test('gsd-pristine/ is regenerated for missing entries when file is unchanged between releases', () => {
|
||||
const SHARED_RELEASE_CONTENT = '# Shared Content\nThis file is identical in v1 and v2.\n';
|
||||
const USER_MODIFIED_CONTENT = '# Shared Content\nThis file is identical in v1 and v2.\n## User addition\nCustom.\n';
|
||||
|
||||
const oldHash = sha256(SHARED_RELEASE_CONTENT);
|
||||
const relPath = 'test-unchanged-file.md';
|
||||
|
||||
// configDir has user-modified file + manifest with old-release hash
|
||||
fs.writeFileSync(path.join(configDir, relPath), USER_MODIFIED_CONTENT);
|
||||
fs.writeFileSync(
|
||||
path.join(configDir, MANIFEST_NAME),
|
||||
JSON.stringify({ version: '1.0.0', files: { [relPath]: oldHash } }, null, 2)
|
||||
);
|
||||
|
||||
// fakeSrcDir (new release) has the SAME content — file was not changed upstream
|
||||
fs.writeFileSync(path.join(fakeSrcDir, relPath), SHARED_RELEASE_CONTENT);
|
||||
|
||||
// NOTE: gsd-pristine/ does NOT exist (simulating post-buggy-run or first-time scenario)
|
||||
|
||||
INSTALL.saveLocalPatches(configDir, {
|
||||
packageSrc: fakeSrcDir,
|
||||
runtime: 'claude',
|
||||
pathPrefix: '$HOME/.claude/',
|
||||
isGlobal: true,
|
||||
});
|
||||
|
||||
// The regeneration path should have detected that sha256(new-release candidate)
|
||||
// === originalHash, and promoted the candidate into gsd-pristine/.
|
||||
const pristineFile = path.join(configDir, 'gsd-pristine', relPath);
|
||||
assert.ok(
|
||||
fs.existsSync(pristineFile),
|
||||
[
|
||||
`gsd-pristine/${relPath} must exist after hash-validated regeneration.`,
|
||||
`When new-release bytes hash to originalHash, the file was unchanged between`,
|
||||
`releases and the candidate should be promoted to restore the pristine baseline.`,
|
||||
].join(' ')
|
||||
);
|
||||
|
||||
const actualContent = fs.readFileSync(pristineFile, 'utf8');
|
||||
assert.equal(
|
||||
sha256(actualContent),
|
||||
oldHash,
|
||||
[
|
||||
`gsd-pristine/${relPath} must contain bytes matching originalHash after regeneration`,
|
||||
`(sha256=${oldHash.slice(0, 12)}…)`,
|
||||
].join(' ')
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Stale-pristine recovery test (pre-fix bug artifact).
|
||||
*
|
||||
* Timeline:
|
||||
* Buggy run: gsd-pristine/<rel> was written with NEW_RELEASE_CONTENT
|
||||
* (the exact #3407 artifact — stale bytes from a buggy populatePristineDir).
|
||||
* Fix run: saveLocalPatches detects the hash mismatch
|
||||
* (sha256(NEW_RELEASE_CONTENT) !== originalHash recorded in manifest),
|
||||
* removes the stale entry, then attempts regeneration.
|
||||
*
|
||||
* When the file CHANGED between releases (NEW !== OLD):
|
||||
* - The stale entry is removed.
|
||||
* - Regeneration discards the new-release candidate (hash mismatch).
|
||||
* - gsd-pristine/<rel> must be ABSENT (over-broad fallback — correct).
|
||||
*
|
||||
* When the file is UNCHANGED between releases (NEW === OLD):
|
||||
* - The stale entry (which happens to have correct bytes despite the bug) is
|
||||
* detected as correct (hash matches originalHash) and PRESERVED.
|
||||
* - gsd-pristine/<rel> must remain present with the correct bytes.
|
||||
*
|
||||
* This test covers the "file changed across release boundary" case.
|
||||
* The "unchanged" case is already covered by the regeneration test above.
|
||||
*/
|
||||
test('stale gsd-pristine/ entry (new-release bytes) is removed when file changed between releases', () => {
|
||||
const OLD_RELEASE_CONTENT = '# Old Release\nv1 content here.\n';
|
||||
const NEW_RELEASE_CONTENT = '# New Release\nv2 content — upstream changed this.\n';
|
||||
const USER_MODIFIED_CONTENT = '# Old Release\nv1 content here.\n## User section\nCustom work.\n';
|
||||
|
||||
const oldHash = sha256(OLD_RELEASE_CONTENT);
|
||||
const relPath = 'test-stale-recovery.md';
|
||||
|
||||
// configDir: user-modified file + manifest recording OLD hash
|
||||
fs.writeFileSync(path.join(configDir, relPath), USER_MODIFIED_CONTENT);
|
||||
fs.writeFileSync(
|
||||
path.join(configDir, MANIFEST_NAME),
|
||||
JSON.stringify({ version: '1.0.0', files: { [relPath]: oldHash } }, null, 2)
|
||||
);
|
||||
|
||||
// fakeSrcDir (new release): contains the NEW content
|
||||
fs.writeFileSync(path.join(fakeSrcDir, relPath), NEW_RELEASE_CONTENT);
|
||||
|
||||
// Pre-populate gsd-pristine/ with NEW_RELEASE_CONTENT — the exact pre-fix bug artifact.
|
||||
// This simulates a prior buggy run that wrote new-release bytes into the pristine baseline.
|
||||
const STALE_BYTES = NEW_RELEASE_CONTENT; // named constant for clarity
|
||||
const pristineDir = path.join(configDir, 'gsd-pristine');
|
||||
fs.mkdirSync(pristineDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(pristineDir, relPath), STALE_BYTES);
|
||||
|
||||
// Verify the pre-condition: stale bytes do NOT match the original hash.
|
||||
// If this assert fails, the test fixture is wrong (not a fix regression).
|
||||
assert.notEqual(
|
||||
sha256(STALE_BYTES),
|
||||
oldHash,
|
||||
'test fixture check: stale bytes must differ from originalHash'
|
||||
);
|
||||
|
||||
INSTALL.saveLocalPatches(configDir, {
|
||||
packageSrc: fakeSrcDir,
|
||||
runtime: 'claude',
|
||||
pathPrefix: '$HOME/.claude/',
|
||||
isGlobal: true,
|
||||
});
|
||||
|
||||
// The fix must detect the hash mismatch (stale entry) and remove it.
|
||||
// The regeneration path discards the new-release candidate (its hash !== oldHash).
|
||||
// Result: gsd-pristine/<rel> must be ABSENT — over-broad fallback is the safe outcome.
|
||||
const pristineFile = path.join(pristineDir, relPath);
|
||||
assert.strictEqual(
|
||||
fs.existsSync(pristineFile),
|
||||
false,
|
||||
[
|
||||
`expected gsd-pristine/${relPath} to be absent after stale-pristine recovery.`,
|
||||
`The stale entry (new-release bytes, sha256=${sha256(STALE_BYTES).slice(0, 12)}…)`,
|
||||
`must be removed; regeneration must discard the candidate because`,
|
||||
`sha256(new-release)=${sha256(NEW_RELEASE_CONTENT).slice(0, 12)}… !== originalHash=${oldHash.slice(0, 12)}….`,
|
||||
`Presence of the file means the stale bytes were NOT cleaned up (pre-fix behavior).`,
|
||||
].join(' ')
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Second scenario: gsd-pristine/ does NOT pre-exist (first upgrade with no
|
||||
* prior pristine population). In this case there is no way to obtain the
|
||||
* old-release pristine bytes — populatePristineDir must NOT write the new-
|
||||
* release bytes either. The correct outcome is: gsd-pristine/ stays empty
|
||||
* for this file, and the verifier falls back to over-broad mode (safe).
|
||||
*/
|
||||
test('gsd-pristine/ stays empty when no prior pristine exists (first upgrade, no stale write)', () => {
|
||||
const OLD_RELEASE_CONTENT = '# Old Release Content\nThis is v1.\n';
|
||||
const NEW_RELEASE_CONTENT = '# New Release Content\nThis is v2 — changed.\n';
|
||||
const USER_MODIFIED_CONTENT = '# Old Release Content\nThis is v1.\n## User addition\nCustom.\n';
|
||||
|
||||
const oldHash = sha256(OLD_RELEASE_CONTENT);
|
||||
const relPath = 'test-first-upgrade.md';
|
||||
|
||||
// configDir has user-modified file + manifest
|
||||
fs.writeFileSync(path.join(configDir, relPath), USER_MODIFIED_CONTENT);
|
||||
fs.writeFileSync(
|
||||
path.join(configDir, MANIFEST_NAME),
|
||||
JSON.stringify({ version: '1.0.0', files: { [relPath]: oldHash } }, null, 2)
|
||||
);
|
||||
|
||||
// fakeSrcDir (new release) has new content
|
||||
fs.writeFileSync(path.join(fakeSrcDir, relPath), NEW_RELEASE_CONTENT);
|
||||
|
||||
// NOTE: gsd-pristine/ does NOT exist yet (first upgrade)
|
||||
|
||||
INSTALL.saveLocalPatches(configDir, {
|
||||
packageSrc: fakeSrcDir,
|
||||
runtime: 'claude',
|
||||
pathPrefix: '$HOME/.claude/',
|
||||
isGlobal: true,
|
||||
});
|
||||
|
||||
const pristineFile = path.join(configDir, 'gsd-pristine', relPath);
|
||||
assert.strictEqual(
|
||||
fs.existsSync(pristineFile),
|
||||
false,
|
||||
[
|
||||
`expected gsd-pristine/${relPath} to be absent when file changed across release boundary.`,
|
||||
`Writing new-release bytes as pristine for a file whose hash is unknown leads to`,
|
||||
`false FAIL_USER_LINES_MISSING in the reapply-patches verifier (#3407).`,
|
||||
`Over-broad fallback mode is the correct outcome here.`,
|
||||
].join(' ')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// The former "Antipattern hunt" describe block (structural typeof checks only) was
|
||||
// removed — it provided no real behavioral coverage and was a vacuous-truth pattern
|
||||
// per /test-rigor skill. Behavioral tests for populatePristineDir are covered above.
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -206,3 +206,235 @@ describe('containsResolvedHomedir predicate (#3503)', () => {
|
||||
assert.strictEqual(containsResolvedHomedir('$HOME/.claude/', '$HOME'), false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-2376-opencode-windows-home-path.test.cjs — consolidation epic #1969 (B1 #1970)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-2376-opencode-windows-home-path (consolidation epic #1969 B1 #1970)", () => {
|
||||
/**
|
||||
* Regression test for #2376: @$HOME not correctly mapped in OpenCode on Windows.
|
||||
*
|
||||
* On Windows, $HOME is not expanded by PowerShell/cmd.exe, so OpenCode cannot
|
||||
* resolve @$HOME/... file references in installed command files.
|
||||
*
|
||||
* Fix: install.js must use the absolute path (not $HOME-relative) when installing
|
||||
* for OpenCode. (Generalized to all platforms in #2831 — OpenCode `@file`
|
||||
* references are not shell-expanded on any platform.)
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
let computePathPrefix;
|
||||
|
||||
before(() => {
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
// Re-require fresh in case other tests already loaded it.
|
||||
delete require.cache[require.resolve('../bin/install.js')];
|
||||
({ computePathPrefix } = require('../bin/install.js'));
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.GSD_TEST_MODE;
|
||||
});
|
||||
|
||||
describe('bug-2376: OpenCode on Windows must use absolute path, not $HOME', () => {
|
||||
test('computePathPrefix is exported by install.js', () => {
|
||||
assert.equal(typeof computePathPrefix, 'function');
|
||||
});
|
||||
|
||||
test('OpenCode on Windows: pathPrefix is absolute (no $HOME substitution)', () => {
|
||||
const pathPrefix = computePathPrefix({
|
||||
isGlobal: true,
|
||||
isOpencode: true,
|
||||
isWindowsHost: true,
|
||||
resolvedTarget: 'C:/Users/user/.config/opencode',
|
||||
homeDir: 'C:/Users/user',
|
||||
});
|
||||
assert.strictEqual(pathPrefix, 'C:/Users/user/.config/opencode/');
|
||||
assert.ok(!pathPrefix.includes('$HOME'));
|
||||
});
|
||||
|
||||
test('Claude Code on Windows: pathPrefix still uses $HOME (unaffected)', () => {
|
||||
const pathPrefix = computePathPrefix({
|
||||
isGlobal: true,
|
||||
isOpencode: false,
|
||||
isWindowsHost: true,
|
||||
resolvedTarget: 'C:/Users/user/.claude',
|
||||
homeDir: 'C:/Users/user',
|
||||
});
|
||||
assert.strictEqual(pathPrefix, '$HOME/.claude/');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Folded from tests/bug-2831-opencode-home-path-prefix.test.cjs — consolidation epic #1969 (B1 #1970)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
const { describe: __foldDescribe } = require('node:test');
|
||||
__foldDescribe("folded:bug-2831-opencode-home-path-prefix (consolidation epic #1969 B1 #1970)", () => {
|
||||
// allow-test-rule: source-text-is-the-product (see #2831)
|
||||
// Workflow .md / agent .md / command .md / reference .md files — their text
|
||||
// IS what the runtime loads. Testing text content tests the deployed contract.
|
||||
// Per CONTRIBUTING.md exception matrix.
|
||||
|
||||
/**
|
||||
* Regression test for #2831: OpenCode @file references contain literal `$HOME`
|
||||
* which OpenCode does not expand — `@$HOME/.config/opencode/...` is resolved
|
||||
* as a path relative to the config command/ dir, producing
|
||||
* `command/$HOME/.config/opencode/...` (file not found).
|
||||
*
|
||||
* Root cause: install.js pathPrefix used `$HOME`-relative paths for OpenCode on
|
||||
* non-Windows hosts (only Windows was guarded by #2376). OpenCode's `@file`
|
||||
* include syntax does NOT shell-expand `$HOME` on any platform.
|
||||
*
|
||||
* Fix: pathPrefix must use the absolute path for OpenCode on all platforms.
|
||||
*
|
||||
* Tests exercise install.js's exported `computePathPrefix` directly (no source
|
||||
* grepping) and additionally simulate the `copyFlattenedCommands` substitution
|
||||
* pipeline on a temp tree to verify no `$HOME` literal leaks into emitted files.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
let computePathPrefix;
|
||||
|
||||
before(() => {
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
delete require.cache[require.resolve('../bin/install.js')];
|
||||
({ computePathPrefix } = require('../bin/install.js'));
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.GSD_TEST_MODE;
|
||||
});
|
||||
|
||||
describe('bug-2831: OpenCode pathPrefix uses absolute path on all platforms', () => {
|
||||
test('computePathPrefix is exported by install.js', () => {
|
||||
assert.equal(typeof computePathPrefix, 'function');
|
||||
});
|
||||
|
||||
test('OpenCode on macOS: pathPrefix is absolute (no $HOME)', () => {
|
||||
const pathPrefix = computePathPrefix({
|
||||
isGlobal: true,
|
||||
isOpencode: true,
|
||||
isWindowsHost: false,
|
||||
homeDir: '/Users/alice',
|
||||
resolvedTarget: '/Users/alice/.config/opencode',
|
||||
});
|
||||
assert.strictEqual(pathPrefix, '/Users/alice/.config/opencode/');
|
||||
assert.ok(!pathPrefix.includes('$HOME'));
|
||||
});
|
||||
|
||||
test('OpenCode on Linux: pathPrefix is absolute (no $HOME)', () => {
|
||||
const pathPrefix = computePathPrefix({
|
||||
isGlobal: true,
|
||||
isOpencode: true,
|
||||
isWindowsHost: false,
|
||||
homeDir: '/home/bob',
|
||||
resolvedTarget: '/home/bob/.config/opencode',
|
||||
});
|
||||
assert.strictEqual(pathPrefix, '/home/bob/.config/opencode/');
|
||||
assert.ok(!pathPrefix.includes('$HOME'));
|
||||
});
|
||||
|
||||
test('OpenCode on Windows: pathPrefix is absolute (preserves #2376)', () => {
|
||||
const pathPrefix = computePathPrefix({
|
||||
isGlobal: true,
|
||||
isOpencode: true,
|
||||
isWindowsHost: true,
|
||||
homeDir: 'C:/Users/carol',
|
||||
resolvedTarget: 'C:/Users/carol/.config/opencode',
|
||||
});
|
||||
assert.strictEqual(pathPrefix, 'C:/Users/carol/.config/opencode/');
|
||||
});
|
||||
|
||||
test('Claude Code on macOS: pathPrefix still uses $HOME (unaffected)', () => {
|
||||
const pathPrefix = computePathPrefix({
|
||||
isGlobal: true,
|
||||
isOpencode: false,
|
||||
isWindowsHost: false,
|
||||
homeDir: '/Users/alice',
|
||||
resolvedTarget: '/Users/alice/.claude',
|
||||
});
|
||||
assert.strictEqual(pathPrefix, '$HOME/.claude/');
|
||||
});
|
||||
|
||||
test('Local install (non-global): pathPrefix uses absolute path regardless of runtime', () => {
|
||||
const pathPrefix = computePathPrefix({
|
||||
isGlobal: false,
|
||||
isOpencode: false,
|
||||
isWindowsHost: false,
|
||||
homeDir: '/Users/alice',
|
||||
resolvedTarget: '/Users/alice/projects/foo/.claude',
|
||||
});
|
||||
assert.strictEqual(pathPrefix, '/Users/alice/projects/foo/.claude/');
|
||||
});
|
||||
|
||||
test('Substitution pipeline simulation: OpenCode emits no @$HOME literal', () => {
|
||||
// This validates the same regex substitution pipeline used by
|
||||
// copyFlattenedCommands when writing OpenCode command files. We invoke the
|
||||
// real exported computePathPrefix; the regex passes mirror the install.js
|
||||
// call sites (globalClaudeRegex / globalClaudeHomeRegex).
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2831-'));
|
||||
try {
|
||||
const srcRoot = path.join(tmp, 'src');
|
||||
const targetRoot = path.join(tmp, 'home', '.config', 'opencode');
|
||||
const srcCmdDir = path.join(srcRoot, 'commands', 'gsd');
|
||||
fs.mkdirSync(srcCmdDir, { recursive: true });
|
||||
fs.mkdirSync(targetRoot, { recursive: true });
|
||||
|
||||
const srcFile = path.join(srcCmdDir, 'autonomous.md');
|
||||
fs.writeFileSync(
|
||||
srcFile,
|
||||
'---\nname: autonomous\n---\n<execution_context>\n@~/.claude/gsd-core/workflows/autonomous.md\n@$HOME/.claude/gsd-core/references/ui-brand.md\n</execution_context>\n'
|
||||
);
|
||||
|
||||
const homeDir = path.join(tmp, 'home').replace(/\\/g, '/');
|
||||
const resolvedTarget = targetRoot.replace(/\\/g, '/');
|
||||
const pathPrefix = computePathPrefix({
|
||||
isGlobal: true,
|
||||
isOpencode: true,
|
||||
isWindowsHost: false,
|
||||
homeDir,
|
||||
resolvedTarget,
|
||||
});
|
||||
|
||||
let content = fs.readFileSync(srcFile, 'utf8');
|
||||
content = content.replace(/~\/\.claude\//g, pathPrefix);
|
||||
content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
|
||||
|
||||
assert.ok(
|
||||
!/@\$HOME\b/.test(content),
|
||||
`output must not contain @$HOME literal; got:\n${content}`
|
||||
);
|
||||
assert.ok(
|
||||
!/\$HOME\b/.test(content),
|
||||
`output must not contain $HOME literal; got:\n${content}`
|
||||
);
|
||||
assert.ok(
|
||||
content.includes(`@${resolvedTarget}/`),
|
||||
`output should include absolute path with @ prefix; got:\n${content}`
|
||||
);
|
||||
} finally {
|
||||
cleanup(tmp);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user