test-package-codex-plugin.sh 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  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" "./hooks/hooks-codex.json" "source Codex manifest declares the Codex hook config"
  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/hooks\.json$|^hooks/hooks-cursor\.json$|^hooks/session-start$|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" "hooks/hooks-codex.json" "archive includes Codex hook config"
  143. assert_contains "$archive_paths" "hooks/session-start-codex" "archive includes Codex hook script"
  144. assert_contains "$archive_paths" "hooks/run-hook.cmd" "archive includes hook runner"
  145. assert_contains "$archive_paths" "skills/brainstorming/agents/openai.yaml" "archive includes OpenAI skill metadata"
  146. assert_contains "$archive_paths" "assets/app-icon.png" "archive includes app icon"
  147. assert_contains "$archive_paths" "assets/superpowers-small.svg" "archive includes composer icon"
  148. 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"))]))')"
  149. expected_version="$(python3 -c 'import json; print(json.load(open("'"$REPO_ROOT"'/.codex-plugin/plugin.json"))["version"])')"
  150. assert_equals "$manifest_summary" "superpowers $expected_version ./skills/ $source_hooks" "archive manifest preserves source hooks"
  151. skill_count="$(find "$extracted/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')"
  152. metadata_count="$(find "$extracted/skills" -path '*/agents/openai.yaml' -type f | wc -l | tr -d ' ')"
  153. assert_equals "$metadata_count" "$skill_count" "every packaged skill has OpenAI metadata"
  154. if [[ -x "$extracted/skills/subagent-driven-development/scripts/task-brief" ]]; then
  155. pass "archive preserves executable script mode"
  156. else
  157. fail "archive preserves executable script mode"
  158. fi
  159. zip_times="$(python3 - "$archive" <<'PY'
  160. import sys
  161. import zipfile
  162. with zipfile.ZipFile(sys.argv[1]) as archive:
  163. print("\n".join(sorted({str(info.date_time) for info in archive.infolist()})))
  164. PY
  165. )"
  166. assert_equals "$zip_times" "(1980, 1, 1, 0, 0, 0)" "zip archive normalizes entry timestamps"
  167. if tar_output="$("$SCRIPT_UNDER_TEST" --allow-dirty --metadata-source "$metadata_source" --format tar.gz --output "$tar_archive" 2>&1)"; then
  168. pass "package script writes explicit tar.gz archive"
  169. else
  170. fail "package script writes explicit tar.gz archive"
  171. printf '%s\n' "$tar_output" | sed 's/^/ /'
  172. fi
  173. assert_contains "$tar_output" "Format: tar.gz" "reports explicit tar.gz format"
  174. extract_archive "$tar_archive" "$tar_extracted"
  175. tar_archive_paths="$(list_archive "$tar_archive" | normalize_archive_paths)"
  176. assert_equals "$tar_archive_paths" "$archive_paths" "zip and tar.gz archives contain the same paths"
  177. tar_task_brief_mode="$(tar -tzvf "$tar_archive" skills/subagent-driven-development/scripts/task-brief | awk '{print $1}')"
  178. assert_equals "$tar_task_brief_mode" "-rwxr-xr-x" "tar.gz archive preserves executable script mode"
  179. tar_metadata_times="$(python3 - "$tar_archive" <<'PY'
  180. import sys, tarfile
  181. with tarfile.open(sys.argv[1]) as archive:
  182. print(sorted({member.mtime for member in archive.getmembers()}))
  183. PY
  184. )"
  185. assert_equals "$tar_metadata_times" "[0]" "tar.gz archive normalizes entry timestamps"
  186. metadata_archive="$TEST_ROOT/metadata-source.tar.gz"
  187. metadata_zip="$TEST_ROOT/metadata-source.zip"
  188. archive_from_tar_source="$TEST_ROOT/superpowers-from-tar-source.zip"
  189. archive_from_zip_source="$TEST_ROOT/superpowers-from-zip-source.zip"
  190. (
  191. cd "$metadata_source"
  192. tar -czf "$metadata_archive" .
  193. zip -X -q -r "$metadata_zip" .
  194. )
  195. if output="$("$SCRIPT_UNDER_TEST" --allow-dirty --metadata-source "$metadata_archive" --output "$archive_from_tar_source" 2>&1)"; then
  196. pass "package script accepts tarball metadata source"
  197. else
  198. fail "package script accepts tarball metadata source"
  199. printf '%s\n' "$output" | sed 's/^/ /'
  200. fi
  201. if cmp -s "$archive" "$archive_from_tar_source"; then
  202. pass "tarball metadata source produces identical archive"
  203. else
  204. fail "tarball metadata source produces identical archive"
  205. fi
  206. if output="$("$SCRIPT_UNDER_TEST" --allow-dirty --metadata-source "$metadata_zip" --output "$archive_from_zip_source" 2>&1)"; then
  207. pass "package script accepts zip metadata source"
  208. else
  209. fail "package script accepts zip metadata source"
  210. printf '%s\n' "$output" | sed 's/^/ /'
  211. fi
  212. if cmp -s "$archive" "$archive_from_zip_source"; then
  213. pass "zip metadata source produces identical archive"
  214. else
  215. fail "zip metadata source produces identical archive"
  216. fi
  217. incomplete_metadata="$TEST_ROOT/incomplete-metadata"
  218. mkdir -p "$incomplete_metadata/skills/brainstorming/agents"
  219. cp "$metadata_source/skills/brainstorming/agents/openai.yaml" \
  220. "$incomplete_metadata/skills/brainstorming/agents/openai.yaml"
  221. set +e
  222. missing_output="$("$SCRIPT_UNDER_TEST" --allow-dirty --metadata-source "$incomplete_metadata" --output "$TEST_ROOT/missing.tar.gz" 2>&1)"
  223. missing_status=$?
  224. set -e
  225. if [[ "$missing_status" -ne 0 ]]; then
  226. pass "package script rejects incomplete metadata source"
  227. else
  228. fail "package script rejects incomplete metadata source"
  229. fi
  230. assert_contains "$missing_output" "ERROR: metadata source is incomplete" "incomplete metadata reports clear error"
  231. dirty_repo="$TEST_ROOT/dirty-repo"
  232. git clone -q --no-local "$REPO_ROOT" "$dirty_repo"
  233. printf '\n# dirty fixture\n' >>"$dirty_repo/README.md"
  234. set +e
  235. dirty_output="$(
  236. cd "$dirty_repo"
  237. scripts/package-codex-plugin.sh \
  238. --metadata-source "$metadata_source" \
  239. --output "$TEST_ROOT/dirty.zip" 2>&1
  240. )"
  241. dirty_status=$?
  242. set -e
  243. if [[ "$dirty_status" -ne 0 ]]; then
  244. pass "package script rejects dirty worktree by default"
  245. else
  246. fail "package script rejects dirty worktree by default"
  247. fi
  248. assert_contains "$dirty_output" "Working tree has uncommitted changes:" "dirty worktree reports changed files"
  249. if [[ "$FAILURES" -eq 0 ]]; then
  250. echo "All Codex package archive tests passed"
  251. else
  252. echo "$FAILURES Codex package archive test(s) failed"
  253. exit 1
  254. fi