test-package-codex-plugin.sh 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. #!/usr/bin/env bash
  2. set -euo pipefail
  3. SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
  4. REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
  5. SCRIPT_UNDER_TEST="$REPO_ROOT/scripts/package-codex-plugin.sh"
  6. FAILURES=0
  7. TEST_ROOT="$(mktemp -d)"
  8. cleanup() {
  9. rm -rf "$TEST_ROOT"
  10. }
  11. trap cleanup EXIT
  12. pass() {
  13. echo " [PASS] $1"
  14. }
  15. fail() {
  16. echo " [FAIL] $1"
  17. FAILURES=$((FAILURES + 1))
  18. }
  19. assert_equals() {
  20. local actual="$1"
  21. local expected="$2"
  22. local description="$3"
  23. if [[ "$actual" == "$expected" ]]; then
  24. pass "$description"
  25. else
  26. fail "$description"
  27. echo " expected: $expected"
  28. echo " actual: $actual"
  29. fi
  30. }
  31. assert_contains() {
  32. local haystack="$1"
  33. local needle="$2"
  34. local description="$3"
  35. if printf '%s' "$haystack" | grep -Fq -- "$needle"; then
  36. pass "$description"
  37. else
  38. fail "$description"
  39. echo " expected to find: $needle"
  40. fi
  41. }
  42. assert_not_matches() {
  43. local haystack="$1"
  44. local pattern="$2"
  45. local description="$3"
  46. if printf '%s' "$haystack" | grep -Eq -- "$pattern"; then
  47. fail "$description"
  48. echo " did not expect to match: $pattern"
  49. else
  50. pass "$description"
  51. fi
  52. }
  53. list_archive() {
  54. local archive_path="$1"
  55. case "$archive_path" in
  56. *.tar.gz|*.tgz)
  57. tar -tzf "$archive_path"
  58. ;;
  59. *.zip)
  60. unzip -Z1 "$archive_path"
  61. ;;
  62. *)
  63. unzip -Z1 "$archive_path"
  64. ;;
  65. esac
  66. }
  67. normalize_archive_paths() {
  68. sed 's#/$##' | LC_ALL=C sort
  69. }
  70. extract_archive() {
  71. local archive_path="$1"
  72. local destination="$2"
  73. mkdir -p "$destination"
  74. case "$archive_path" in
  75. *.tar.gz|*.tgz)
  76. tar -xzf "$archive_path" -C "$destination"
  77. ;;
  78. *.zip)
  79. unzip -q "$archive_path" -d "$destination"
  80. ;;
  81. *)
  82. unzip -q "$archive_path" -d "$destination"
  83. ;;
  84. esac
  85. }
  86. read_archive_file() {
  87. local archive_path="$1"
  88. local file_path="$2"
  89. case "$archive_path" in
  90. *.tar.gz|*.tgz)
  91. tar -xOf "$archive_path" "$file_path"
  92. ;;
  93. *.zip)
  94. unzip -p "$archive_path" "$file_path"
  95. ;;
  96. *)
  97. unzip -p "$archive_path" "$file_path"
  98. ;;
  99. esac
  100. }
  101. write_metadata_fixture() {
  102. local destination="$1"
  103. local skill
  104. while IFS= read -r skill; do
  105. mkdir -p "$destination/skills/$skill/agents"
  106. cat >"$destination/skills/$skill/agents/openai.yaml" <<EOF
  107. interface:
  108. display_name: "$skill"
  109. short_description: "Fixture metadata for $skill"
  110. EOF
  111. done < <(find "$REPO_ROOT/skills" -mindepth 1 -maxdepth 1 -type d -print | sed 's#.*/##' | sort)
  112. }
  113. echo "Codex package archive tests"
  114. metadata_source="$TEST_ROOT/metadata-source"
  115. archive="$TEST_ROOT/superpowers"
  116. tar_archive="$TEST_ROOT/superpowers.tar.gz"
  117. extracted="$TEST_ROOT/extracted"
  118. tar_extracted="$TEST_ROOT/tar-extracted"
  119. write_metadata_fixture "$metadata_source"
  120. source_hooks="$(python3 -c 'import json; print(json.load(open("'"$REPO_ROOT"'/.codex-plugin/plugin.json")).get("hooks"))')"
  121. assert_equals "$source_hooks" "{}" "source Codex manifest suppresses local hook auto-discovery"
  122. if output="$("$SCRIPT_UNDER_TEST" --allow-dirty --metadata-source "$metadata_source" --output "$archive" 2>&1)"; then
  123. pass "package script exits successfully"
  124. else
  125. fail "package script exits successfully"
  126. printf '%s\n' "$output" | sed 's/^/ /'
  127. fi
  128. if [[ -f "$archive" ]]; then
  129. pass "package script writes archive"
  130. else
  131. fail "package script writes archive"
  132. fi
  133. assert_contains "$output" "Archive:" "reports archive path"
  134. assert_contains "$output" "Format: zip" "reports default zip format"
  135. assert_contains "$output" "SHA-256:" "reports archive checksum"
  136. extract_archive "$archive" "$extracted"
  137. archive_paths="$(list_archive "$archive" | normalize_archive_paths)"
  138. unexpected_pattern='(^superpowers/|^\.agents/|^hooks/|package\.json$|^\.git|^\.pytest_cache|^\.ruff_cache|^scripts/|^tests/|^docs/|^evals/|^lib/|^\.claude|^\.cursor|^\.kimi|^\.opencode|^\.pi|^AGENTS\.md$|^CLAUDE\.md$|^GEMINI\.md$|^RELEASE-NOTES\.md$|^CHANGELOG\.md$)'
  139. assert_not_matches "$archive_paths" "$unexpected_pattern" "archive excludes source-only paths"
  140. assert_contains "$archive_paths" ".codex-plugin/plugin.json" "archive includes Codex manifest"
  141. assert_contains "$archive_paths" "skills/brainstorming/SKILL.md" "archive includes skills"
  142. assert_contains "$archive_paths" "skills/brainstorming/agents/openai.yaml" "archive includes OpenAI skill metadata"
  143. assert_contains "$archive_paths" "assets/app-icon.png" "archive includes app icon"
  144. assert_contains "$archive_paths" "assets/superpowers-small.svg" "archive includes composer icon"
  145. manifest_summary="$(read_archive_file "$archive" .codex-plugin/plugin.json | python3 -c 'import json,sys; data=json.load(sys.stdin); print("\t".join([data["name"], data["version"], data["skills"], str(data.get("hooks"))]))')"
  146. expected_version="$(python3 -c 'import json; print(json.load(open("'"$REPO_ROOT"'/.codex-plugin/plugin.json"))["version"])')"
  147. assert_equals "$manifest_summary" "superpowers $expected_version ./skills/ $source_hooks" "archive manifest preserves source hooks"
  148. skill_count="$(find "$extracted/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')"
  149. metadata_count="$(find "$extracted/skills" -path '*/agents/openai.yaml' -type f | wc -l | tr -d ' ')"
  150. assert_equals "$metadata_count" "$skill_count" "every packaged skill has OpenAI metadata"
  151. if [[ -x "$extracted/skills/subagent-driven-development/scripts/task-brief" ]]; then
  152. pass "archive preserves executable script mode"
  153. else
  154. fail "archive preserves executable script mode"
  155. fi
  156. zip_times="$(python3 - "$archive" <<'PY'
  157. import sys
  158. import zipfile
  159. with zipfile.ZipFile(sys.argv[1]) as archive:
  160. print("\n".join(sorted({str(info.date_time) for info in archive.infolist()})))
  161. PY
  162. )"
  163. assert_equals "$zip_times" "(1980, 1, 1, 0, 0, 0)" "zip archive normalizes entry timestamps"
  164. if tar_output="$("$SCRIPT_UNDER_TEST" --allow-dirty --metadata-source "$metadata_source" --format tar.gz --output "$tar_archive" 2>&1)"; then
  165. pass "package script writes explicit tar.gz archive"
  166. else
  167. fail "package script writes explicit tar.gz archive"
  168. printf '%s\n' "$tar_output" | sed 's/^/ /'
  169. fi
  170. assert_contains "$tar_output" "Format: tar.gz" "reports explicit tar.gz format"
  171. extract_archive "$tar_archive" "$tar_extracted"
  172. tar_archive_paths="$(list_archive "$tar_archive" | normalize_archive_paths)"
  173. assert_equals "$tar_archive_paths" "$archive_paths" "zip and tar.gz archives contain the same paths"
  174. tar_task_brief_mode="$(tar -tzvf "$tar_archive" skills/subagent-driven-development/scripts/task-brief | awk '{print $1}')"
  175. assert_equals "$tar_task_brief_mode" "-rwxr-xr-x" "tar.gz archive preserves executable script mode"
  176. tar_metadata_times="$(python3 - "$tar_archive" <<'PY'
  177. import sys, tarfile
  178. with tarfile.open(sys.argv[1]) as archive:
  179. print(sorted({member.mtime for member in archive.getmembers()}))
  180. PY
  181. )"
  182. assert_equals "$tar_metadata_times" "[0]" "tar.gz archive normalizes entry timestamps"
  183. metadata_archive="$TEST_ROOT/metadata-source.tar.gz"
  184. metadata_zip="$TEST_ROOT/metadata-source.zip"
  185. archive_from_tar_source="$TEST_ROOT/superpowers-from-tar-source.zip"
  186. archive_from_zip_source="$TEST_ROOT/superpowers-from-zip-source.zip"
  187. (
  188. cd "$metadata_source"
  189. tar -czf "$metadata_archive" .
  190. zip -X -q -r "$metadata_zip" .
  191. )
  192. if output="$("$SCRIPT_UNDER_TEST" --allow-dirty --metadata-source "$metadata_archive" --output "$archive_from_tar_source" 2>&1)"; then
  193. pass "package script accepts tarball metadata source"
  194. else
  195. fail "package script accepts tarball metadata source"
  196. printf '%s\n' "$output" | sed 's/^/ /'
  197. fi
  198. if cmp -s "$archive" "$archive_from_tar_source"; then
  199. pass "tarball metadata source produces identical archive"
  200. else
  201. fail "tarball metadata source produces identical archive"
  202. fi
  203. if output="$("$SCRIPT_UNDER_TEST" --allow-dirty --metadata-source "$metadata_zip" --output "$archive_from_zip_source" 2>&1)"; then
  204. pass "package script accepts zip metadata source"
  205. else
  206. fail "package script accepts zip metadata source"
  207. printf '%s\n' "$output" | sed 's/^/ /'
  208. fi
  209. if cmp -s "$archive" "$archive_from_zip_source"; then
  210. pass "zip metadata source produces identical archive"
  211. else
  212. fail "zip metadata source produces identical archive"
  213. fi
  214. incomplete_metadata="$TEST_ROOT/incomplete-metadata"
  215. mkdir -p "$incomplete_metadata/skills/brainstorming/agents"
  216. cp "$metadata_source/skills/brainstorming/agents/openai.yaml" \
  217. "$incomplete_metadata/skills/brainstorming/agents/openai.yaml"
  218. set +e
  219. missing_output="$("$SCRIPT_UNDER_TEST" --allow-dirty --metadata-source "$incomplete_metadata" --output "$TEST_ROOT/missing.tar.gz" 2>&1)"
  220. missing_status=$?
  221. set -e
  222. if [[ "$missing_status" -ne 0 ]]; then
  223. pass "package script rejects incomplete metadata source"
  224. else
  225. fail "package script rejects incomplete metadata source"
  226. fi
  227. assert_contains "$missing_output" "ERROR: metadata source is incomplete" "incomplete metadata reports clear error"
  228. dirty_repo="$TEST_ROOT/dirty-repo"
  229. git clone -q --no-local "$REPO_ROOT" "$dirty_repo"
  230. printf '\n# dirty fixture\n' >>"$dirty_repo/README.md"
  231. set +e
  232. dirty_output="$(
  233. cd "$dirty_repo"
  234. scripts/package-codex-plugin.sh \
  235. --metadata-source "$metadata_source" \
  236. --output "$TEST_ROOT/dirty.zip" 2>&1
  237. )"
  238. dirty_status=$?
  239. set -e
  240. if [[ "$dirty_status" -ne 0 ]]; then
  241. pass "package script rejects dirty worktree by default"
  242. else
  243. fail "package script rejects dirty worktree by default"
  244. fi
  245. assert_contains "$dirty_output" "Working tree has uncommitted changes:" "dirty worktree reports changed files"
  246. if [[ "$FAILURES" -eq 0 ]]; then
  247. echo "All Codex package archive tests passed"
  248. else
  249. echo "$FAILURES Codex package archive test(s) failed"
  250. exit 1
  251. fi