test-bootstrap-caching.sh 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388
  1. #!/usr/bin/env bash
  2. # Test: Bootstrap Content Caching (#1202)
  3. # Verifies that getBootstrapContent() caches at module level,
  4. # eliminating per-step file I/O and regex parsing overhead.
  5. set -euo pipefail
  6. SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
  7. echo "=== Test: Bootstrap Content Caching (#1202) ==="
  8. # Source setup to create isolated environment
  9. source "$SCRIPT_DIR/setup.sh"
  10. # Trap to cleanup on exit
  11. trap cleanup_test_env EXIT
  12. passed=0
  13. failed=0
  14. pass() { echo " [PASS] $1"; passed=$((passed + 1)); }
  15. fail() { echo " [FAIL] $1"; failed=$((failed + 1)); }
  16. # ──────────────────────────────────────────────────────────────
  17. # Test 1: Module-level _bootstrapCache variable exists
  18. # ──────────────────────────────────────────────────────────────
  19. echo "Test 1: Module-level cache variable exists..."
  20. if grep -q '_bootstrapCache' "$SUPERPOWERS_PLUGIN_FILE"; then
  21. pass "Module-level _bootstrapCache variable found"
  22. else
  23. fail "_bootstrapCache variable not found in plugin"
  24. fi
  25. # ──────────────────────────────────────────────────────────────
  26. # Test 2: Cache is checked before fs operations
  27. # ──────────────────────────────────────────────────────────────
  28. echo "Test 2: Cache checked before filesystem access..."
  29. # The pattern: if (_bootstrapCache !== undefined) return should appear
  30. # BEFORE any fs.existsSync / fs.readFileSync in getBootstrapContent
  31. if grep -qP '_bootstrapCache !== undefined.*return' "$SUPERPOWERS_PLUGIN_FILE"; then
  32. pass "Early return on cache hit exists"
  33. else
  34. fail "No early return on cache hit"
  35. fi
  36. # ──────────────────────────────────────────────────────────────
  37. # Test 3: Cache is populated after file read
  38. # ──────────────────────────────────────────────────────────────
  39. echo "Test 3: Cache populated after successful file read..."
  40. if grep -q '_bootstrapCache =' "$SUPERPOWERS_PLUGIN_FILE"; then
  41. pass "Cache assignment exists"
  42. else
  43. fail "No cache assignment found"
  44. fi
  45. # ──────────────────────────────────────────────────────────────
  46. # Test 4: Missing file path also cached (null sentinel)
  47. # ──────────────────────────────────────────────────────────────
  48. echo "Test 4: Missing file case also cached (null sentinel)..."
  49. if grep -q '_bootstrapCache = null' "$SUPERPOWERS_PLUGIN_FILE"; then
  50. pass "Null sentinel for missing file path"
  51. else
  52. fail "Missing file path not cached — would re-check fs.existsSync every step"
  53. fi
  54. # ──────────────────────────────────────────────────────────────
  55. # Test 5: No redundant fs.readFileSync on cached path
  56. # Verify via Node.js: call getBootstrapContent() twice, count reads
  57. # ──────────────────────────────────────────────────────────────
  58. echo "Test 5: Second call returns cached content without file I/O..."
  59. cat > "$TEST_HOME/test-cache.mjs" <<'TESTEOF'
  60. import { createRequire } from 'module';
  61. import path from 'path';
  62. import fs from 'fs';
  63. // Monkey-patch fs to count calls
  64. let readCount = 0;
  65. let existsCount = 0;
  66. const origReadFileSync = fs.readFileSync;
  67. const origExistsSync = fs.existsSync;
  68. fs.readFileSync = function(...args) {
  69. readCount++;
  70. return origReadFileSync.apply(this, args);
  71. };
  72. fs.existsSync = function(...args) {
  73. existsCount++;
  74. return origExistsSync.apply(this, args);
  75. };
  76. // Import the plugin (this sets __dirname based on plugin location)
  77. const pluginPath = process.argv[2];
  78. const mod = await import(pluginPath);
  79. // Initialize plugin
  80. const plugin = await mod.SuperpowersPlugin({ client: {}, directory: '.' });
  81. // Reset cache for clean test
  82. mod._testing.resetCache();
  83. // First call — should hit fs
  84. readCount = 0;
  85. existsCount = 0;
  86. const result1 = await plugin['experimental.chat.messages.transform'](
  87. {},
  88. { messages: [{
  89. info: { role: 'user' },
  90. parts: [{ type: 'text', text: 'hello' }]
  91. }]}
  92. );
  93. const firstReadCount = readCount;
  94. const firstExistsCount = existsCount;
  95. // Reset cache state check
  96. const cacheAfterFirst = mod._testing.getCache();
  97. // Reset counters, call again (should be cached)
  98. // Need to reset the injection guard — create fresh messages
  99. readCount = 0;
  100. existsCount = 0;
  101. const result2 = await plugin['experimental.chat.messages.transform'](
  102. {},
  103. { messages: [{
  104. info: { role: 'user' },
  105. parts: [{ type: 'text', text: 'hello again' }]
  106. }]}
  107. );
  108. const secondReadCount = readCount;
  109. const secondExistsCount = existsCount;
  110. // Output results
  111. console.log(JSON.stringify({
  112. firstReadCount,
  113. firstExistsCount,
  114. secondReadCount,
  115. secondExistsCount,
  116. cachePopulated: cacheAfterFirst !== undefined && cacheAfterFirst !== null,
  117. contentIncludesMarker: cacheAfterFirst?.includes('EXTREMELY_IMPORTANT') ?? false
  118. }));
  119. TESTEOF
  120. RESULT=$(node "$TEST_HOME/test-cache.mjs" "$SUPERPOWERS_PLUGIN_FILE" 2>/dev/null)
  121. if [ $? -ne 0 ]; then
  122. fail "Cache test script failed to execute"
  123. else
  124. SECOND_READ=$(echo "$RESULT" | node -e "const d=JSON.parse(require('fs').readFileSync(0,'utf8'));process.stdout.write(String(d.secondReadCount))")
  125. SECOND_EXISTS=$(echo "$RESULT" | node -e "const d=JSON.parse(require('fs').readFileSync(0,'utf8'));process.stdout.write(String(d.secondExistsCount))")
  126. CACHE_POP=$(echo "$RESULT" | node -e "const d=JSON.parse(require('fs').readFileSync(0,'utf8'));process.stdout.write(String(d.cachePopulated))")
  127. CONTENT_OK=$(echo "$RESULT" | node -e "const d=JSON.parse(require('fs').readFileSync(0,'utf8'));process.stdout.write(String(d.contentIncludesMarker))")
  128. if [ "$SECOND_READ" = "0" ] && [ "$SECOND_EXISTS" = "0" ]; then
  129. pass "Second call made 0 fs.readFileSync and 0 fs.existsSync calls"
  130. else
  131. fail "Second call still made fs calls (read=$SECOND_READ, exists=$SECOND_EXISTS)"
  132. fi
  133. if [ "$CACHE_POP" = "true" ]; then
  134. pass "Cache populated after first call"
  135. else
  136. fail "Cache not populated after first call"
  137. fi
  138. if [ "$CONTENT_OK" = "true" ]; then
  139. pass "Cached content contains EXTREMELY_IMPORTANT marker"
  140. else
  141. fail "Cached content missing expected marker"
  142. fi
  143. fi
  144. # ──────────────────────────────────────────────────────────────
  145. # Test 6: _testing.resetCache() clears the cache
  146. # ──────────────────────────────────────────────────────────────
  147. echo "Test 6: resetCache() allows re-reading from disk..."
  148. cat > "$TEST_HOME/test-reset.mjs" <<'TESTEOF'
  149. import fs from 'fs';
  150. const pluginPath = process.argv[2];
  151. const mod = await import(pluginPath);
  152. // Initialize and populate cache
  153. const plugin = await mod.SuperpowersPlugin({ client: {}, directory: '.' });
  154. await plugin['experimental.chat.messages.transform'](
  155. {},
  156. { messages: [{ info: { role: 'user' }, parts: [{ type: 'text', text: 'test' }] }] }
  157. );
  158. const beforeReset = mod._testing.getCache();
  159. mod._testing.resetCache();
  160. const afterReset = mod._testing.getCache();
  161. console.log(JSON.stringify({
  162. beforeResetDefined: beforeReset !== undefined,
  163. afterResetUndefined: afterReset === undefined
  164. }));
  165. TESTEOF
  166. RESULT=$(node "$TEST_HOME/test-reset.mjs" "$SUPERPOWERS_PLUGIN_FILE" 2>/dev/null)
  167. if [ $? -ne 0 ]; then
  168. fail "Reset test script failed to execute"
  169. else
  170. BEFORE=$(echo "$RESULT" | node -e "const d=JSON.parse(require('fs').readFileSync(0,'utf8'));process.stdout.write(String(d.beforeResetDefined))")
  171. AFTER=$(echo "$RESULT" | node -e "const d=JSON.parse(require('fs').readFileSync(0,'utf8'));process.stdout.write(String(d.afterResetUndefined))")
  172. if [ "$BEFORE" = "true" ] && [ "$AFTER" = "true" ]; then
  173. pass "resetCache() transitions from defined to undefined"
  174. else
  175. fail "resetCache() did not clear properly (before=$BEFORE, after=$AFTER)"
  176. fi
  177. fi
  178. # ──────────────────────────────────────────────────────────────
  179. # Test 7: Injection guard prevents double-injection in same array
  180. # ──────────────────────────────────────────────────────────────
  181. echo "Test 7: Injection guard prevents double-injection..."
  182. cat > "$TEST_HOME/test-guard.mjs" <<'TESTEOF'
  183. const pluginPath = process.argv[2];
  184. const mod = await import(pluginPath);
  185. mod._testing.resetCache();
  186. const plugin = await mod.SuperpowersPlugin({ client: {}, directory: '.' });
  187. // Create a message array and inject once
  188. const messages = [{
  189. info: { role: 'user' },
  190. parts: [{ type: 'text', text: 'hello' }]
  191. }];
  192. await plugin['experimental.chat.messages.transform']({}, { messages });
  193. const partsAfterFirst = messages[0].parts.length;
  194. // Call again on the SAME messages array (simulates what would happen
  195. // if the hook fired twice on the same in-memory messages)
  196. await plugin['experimental.chat.messages.transform']({}, { messages });
  197. const partsAfterSecond = messages[0].parts.length;
  198. console.log(JSON.stringify({
  199. partsAfterFirst,
  200. partsAfterSecond,
  201. noDuplication: partsAfterFirst === partsAfterSecond
  202. }));
  203. TESTEOF
  204. RESULT=$(node "$TEST_HOME/test-guard.mjs" "$SUPERPOWERS_PLUGIN_FILE" 2>/dev/null)
  205. if [ $? -ne 0 ]; then
  206. fail "Guard test script failed to execute"
  207. else
  208. NO_DUP=$(echo "$RESULT" | node -e "const d=JSON.parse(require('fs').readFileSync(0,'utf8'));process.stdout.write(String(d.noDuplication))")
  209. FIRST=$(echo "$RESULT" | node -e "const d=JSON.parse(require('fs').readFileSync(0,'utf8'));process.stdout.write(String(d.partsAfterFirst))")
  210. if [ "$NO_DUP" = "true" ]; then
  211. pass "Guard prevented double injection (parts count stable at $FIRST)"
  212. else
  213. fail "Bootstrap injected twice into same message"
  214. fi
  215. fi
  216. # ──────────────────────────────────────────────────────────────
  217. # Test 8: Missing skill file → null cached (no repeated fs probes)
  218. # ──────────────────────────────────────────────────────────────
  219. echo "Test 8: Missing SKILL.md file produces cached null..."
  220. cat > "$TEST_HOME/test-missing.mjs" <<'TESTEOF'
  221. import fs from 'fs';
  222. const pluginPath = process.argv[2];
  223. const mod = await import(pluginPath);
  224. mod._testing.resetCache();
  225. // Temporarily rename the skill file to simulate missing
  226. const skillDir = new URL('../../skills/using-superpowers/SKILL.md', import.meta.resolve(pluginPath));
  227. // We can't easily rename, so instead we'll test by checking the cache
  228. // behavior when _bootstrapCache is set to null
  229. mod._testing.resetCache();
  230. const plugin = await mod.SuperpowersPlugin({ client: {}, directory: '.' });
  231. // Monkey-patch fs.existsSync to return false for SKILL.md
  232. const orig = fs.existsSync;
  233. fs.existsSync = (p) => {
  234. if (typeof p === 'string' && p.includes('using-superpowers')) return false;
  235. return orig(p);
  236. };
  237. let readCount = 0;
  238. const origRead = fs.readFileSync;
  239. fs.readFileSync = function(...args) { readCount++; return origRead.apply(this, args); };
  240. // First call — should set null cache
  241. const msgs1 = [{ info: { role: 'user' }, parts: [{ type: 'text', text: 'test' }] }];
  242. await plugin['experimental.chat.messages.transform']({}, { messages: msgs1 });
  243. const cacheAfterMissing = mod._testing.getCache();
  244. const firstReadCount = readCount;
  245. // Second call — should hit null cache, skip fs entirely
  246. readCount = 0;
  247. const msgs2 = [{ info: { role: 'user' }, parts: [{ type: 'text', text: 'test2' }] }];
  248. await plugin['experimental.chat.messages.transform']({}, { messages: msgs2 });
  249. // Restore
  250. fs.existsSync = orig;
  251. fs.readFileSync = origRead;
  252. console.log(JSON.stringify({
  253. cacheIsNull: cacheAfterMissing === null,
  254. secondCallReads: readCount,
  255. noInjection: msgs1[0].parts.length === 1
  256. }));
  257. TESTEOF
  258. RESULT=$(node "$TEST_HOME/test-missing.mjs" "$SUPERPOWERS_PLUGIN_FILE" 2>/dev/null)
  259. if [ $? -ne 0 ]; then
  260. fail "Missing file test script failed to execute"
  261. else
  262. IS_NULL=$(echo "$RESULT" | node -e "const d=JSON.parse(require('fs').readFileSync(0,'utf8'));process.stdout.write(String(d.cacheIsNull))")
  263. SEC_READS=$(echo "$RESULT" | node -e "const d=JSON.parse(require('fs').readFileSync(0,'utf8'));process.stdout.write(String(d.secondCallReads))")
  264. NO_INJ=$(echo "$RESULT" | node -e "const d=JSON.parse(require('fs').readFileSync(0,'utf8'));process.stdout.write(String(d.noInjection))")
  265. if [ "$IS_NULL" = "true" ]; then
  266. pass "Cache set to null for missing file"
  267. else
  268. fail "Cache not set to null for missing file"
  269. fi
  270. if [ "$SEC_READS" = "0" ]; then
  271. pass "Second call with missing file made 0 fs reads"
  272. else
  273. fail "Second call with missing file still read fs ($SEC_READS times)"
  274. fi
  275. if [ "$NO_INJ" = "true" ]; then
  276. pass "No bootstrap injected when file missing"
  277. else
  278. fail "Bootstrap somehow injected despite missing file"
  279. fi
  280. fi
  281. # ──────────────────────────────────────────────────────────────
  282. # Test 9: Source audit — no uncached fs.readFileSync in getBootstrapContent
  283. # ──────────────────────────────────────────────────────────────
  284. echo "Test 9: Source audit — getBootstrapContent caches all fs paths..."
  285. # Extract getBootstrapContent function body and verify cache pattern
  286. # The function should: check cache → fs.existsSync → fs.readFileSync → assign cache
  287. FUNC_BODY=$(sed -n '/const getBootstrapContent/,/^ };$/p' "$SUPERPOWERS_PLUGIN_FILE")
  288. # Verify the cache check comes before any fs call
  289. CACHE_LINE=$(echo "$FUNC_BODY" | grep -n '_bootstrapCache !== undefined' | head -1 | cut -d: -f1)
  290. EXISTS_LINE=$(echo "$FUNC_BODY" | grep -n 'fs.existsSync' | head -1 | cut -d: -f1)
  291. if [ -n "$CACHE_LINE" ] && [ -n "$EXISTS_LINE" ] && [ "$CACHE_LINE" -lt "$EXISTS_LINE" ]; then
  292. pass "Cache check (line $CACHE_LINE) precedes fs.existsSync (line $EXISTS_LINE)"
  293. else
  294. fail "Cache check does not precede fs.existsSync (cache=$CACHE_LINE, exists=$EXISTS_LINE)"
  295. fi
  296. # ──────────────────────────────────────────────────────────────
  297. # Test 10: JavaScript syntax still valid after changes
  298. # ──────────────────────────────────────────────────────────────
  299. echo "Test 10: Plugin JavaScript syntax remains valid..."
  300. if node --check "$SUPERPOWERS_PLUGIN_FILE" 2>/dev/null; then
  301. pass "Plugin JavaScript syntax is valid"
  302. else
  303. fail "Plugin has JavaScript syntax errors"
  304. fi
  305. # ──────────────────────────────────────────────────────────────
  306. # Test 11: _testing export exists for test infrastructure
  307. # ──────────────────────────────────────────────────────────────
  308. echo "Test 11: _testing export available..."
  309. if grep -q 'export const _testing' "$SUPERPOWERS_PLUGIN_FILE"; then
  310. pass "_testing export exists"
  311. else
  312. fail "_testing export not found"
  313. fi
  314. # ──────────────────────────────────────────────────────────────
  315. # Summary
  316. # ──────────────────────────────────────────────────────────────
  317. echo ""
  318. total=$((passed + failed))
  319. echo "=== Results: $passed/$total passed ==="
  320. if [ "$failed" -gt 0 ]; then
  321. exit 1
  322. fi
  323. echo "=== All bootstrap caching tests passed ==="