@triflare/fx 2.1.0-beta.1 → 2.1.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/package.json +15 -6
  2. package/product.yml +1 -1
  3. package/src/build.js +23 -6
  4. package/src/cli/commands/check.js +3 -1
  5. package/src/cli/commands/feature.js +54 -5
  6. package/src/cli/commands/index.js +2 -0
  7. package/src/cli/commands/lint.js +4 -0
  8. package/src/cli/commands/test.js +186 -0
  9. package/src/cli/context.js +3 -1
  10. package/src/config-edit.js +78 -0
  11. package/src/config.js +77 -0
  12. package/src/features.js +89 -3
  13. package/src/formatter.js +21 -1
  14. package/src/index.js +21 -0
  15. package/src/lint/analyze.js +25 -5
  16. package/src/lint/config-file.js +14 -0
  17. package/src/merge.js +7 -0
  18. package/src/parser.js +78 -3
  19. package/src/project.js +14 -3
  20. package/src/test/discover.js +82 -0
  21. package/src/test/parse.js +72 -0
  22. package/src/test/pattern.js +84 -0
  23. package/src/test/reporters.js +332 -0
  24. package/src/test/runner.js +1457 -0
  25. package/src/vm.js +353 -0
  26. package/vendor/pm-vm-dist/.fx-build.json +7 -0
  27. package/vendor/pm-vm-dist/LICENSE +373 -0
  28. package/vendor/pm-vm-dist/NOTICE +6 -0
  29. package/vendor/pm-vm-dist/dist/iframe-extension-worker.js +1 -0
  30. package/vendor/pm-vm-dist/dist/js/extension-worker/extension-worker.7aa98c98b43d49535069.js +1 -0
  31. package/vendor/pm-vm-dist/dist/media/music/00765be80e1ec0073c7d855eac123b96.mp3 +1 -0
  32. package/vendor/pm-vm-dist/dist/media/music/040abc33bc843ccea91acc82b370a2a5.mp3 +1 -0
  33. package/vendor/pm-vm-dist/dist/media/music/059f32937ff4e15834ad94972c058386.mp3 +1 -0
  34. package/vendor/pm-vm-dist/dist/media/music/0ae12e3be02439395202850a9c469930.mp3 +1 -0
  35. package/vendor/pm-vm-dist/dist/media/music/0ae841a4034e4847205ff93589fe38b7.mp3 +1 -0
  36. package/vendor/pm-vm-dist/dist/media/music/101c38be253a79f22164915a7b41a485.mp3 +1 -0
  37. package/vendor/pm-vm-dist/dist/media/music/1c0f11f5e3bc3a290e3011a863acbf19.mp3 +1 -0
  38. package/vendor/pm-vm-dist/dist/media/music/1e35fc4bf222acbb373be13bef7b494c.mp3 +1 -0
  39. package/vendor/pm-vm-dist/dist/media/music/27470aa23024c67716d745d09d1eeb0d.mp3 +1 -0
  40. package/vendor/pm-vm-dist/dist/media/music/2a2c19ccfb24fd1f611133f46859f697.mp3 +1 -0
  41. package/vendor/pm-vm-dist/dist/media/music/2f42c08774845c95c5afe6aa2011660d.mp3 +1 -0
  42. package/vendor/pm-vm-dist/dist/media/music/317cfa5845ae8684414e36059aa217cf.mp3 +1 -0
  43. package/vendor/pm-vm-dist/dist/media/music/32ab039554155f1cc982d06b17c1b268.mp3 +1 -0
  44. package/vendor/pm-vm-dist/dist/media/music/368b67706d2601ccdb29ba0ae4a06c7d.mp3 +1 -0
  45. package/vendor/pm-vm-dist/dist/media/music/418e78ff0f0202a85f8e51ddceb6383b.mp3 +1 -0
  46. package/vendor/pm-vm-dist/dist/media/music/44fede772a621f62516dafeec7ba6e76.mp3 +1 -0
  47. package/vendor/pm-vm-dist/dist/media/music/45b502e33be78aca1c76962d8f0c9799.mp3 +1 -0
  48. package/vendor/pm-vm-dist/dist/media/music/4efb38b83c696fde2043ad3770fd6cf5.mp3 +1 -0
  49. package/vendor/pm-vm-dist/dist/media/music/555849284aa06b4716e47d7e12341ab4.mp3 +1 -0
  50. package/vendor/pm-vm-dist/dist/media/music/6072de860d1aed016b4b82379510efc7.mp3 +1 -0
  51. package/vendor/pm-vm-dist/dist/media/music/612b52d7a8538850a26cc9a6adda0451.mp3 +1 -0
  52. package/vendor/pm-vm-dist/dist/media/music/6469f82ff2d352bc85e7528635ca6b98.mp3 +1 -0
  53. package/vendor/pm-vm-dist/dist/media/music/66f88d71bbebdff3c79c315946b77516.mp3 +1 -0
  54. package/vendor/pm-vm-dist/dist/media/music/70ce80e25dd12856990938c55f576862.mp3 +1 -0
  55. package/vendor/pm-vm-dist/dist/media/music/79fb30eddc4dd8362cabe7dc80b59da6.mp3 +1 -0
  56. package/vendor/pm-vm-dist/dist/media/music/7f0e67fcb804175c4169eabf81d21040.mp3 +1 -0
  57. package/vendor/pm-vm-dist/dist/media/music/832a2a264fdeb69057ad7208584a49b5.mp3 +1 -0
  58. package/vendor/pm-vm-dist/dist/media/music/83f40654a5eb9dc81f854c2b6f83422c.mp3 +1 -0
  59. package/vendor/pm-vm-dist/dist/media/music/8414e157e2b98d7d4462330c8c4b1cc2.mp3 +1 -0
  60. package/vendor/pm-vm-dist/dist/media/music/8d02b8a9547b47c8506d6c15c913acc1.mp3 +1 -0
  61. package/vendor/pm-vm-dist/dist/media/music/912efc3e11a27b50b879a7ad3bad2eab.mp3 +1 -0
  62. package/vendor/pm-vm-dist/dist/media/music/92771972e8ea5e5e76566218bd193471.mp3 +1 -0
  63. package/vendor/pm-vm-dist/dist/media/music/92e9bc4ec854ae2c3157169fc5ca47a4.mp3 +1 -0
  64. package/vendor/pm-vm-dist/dist/media/music/9b36fd7513f2eeab9876bc75c98262c6.mp3 +1 -0
  65. package/vendor/pm-vm-dist/dist/media/music/9c571ca77e0ff0d1071c39941677febc.mp3 +1 -0
  66. package/vendor/pm-vm-dist/dist/media/music/9d3f26ebd92e02a921b031785dafd6d6.mp3 +1 -0
  67. package/vendor/pm-vm-dist/dist/media/music/9f769e9f239b0a1c048486b24cd9e15b.mp3 +1 -0
  68. package/vendor/pm-vm-dist/dist/media/music/a5a68e9168e8e677cc15494e8680538b.mp3 +1 -0
  69. package/vendor/pm-vm-dist/dist/media/music/a6a3f99085f00a94f2fb5aebfeee2879.mp3 +1 -0
  70. package/vendor/pm-vm-dist/dist/media/music/a6caebbce7ac630fa45cd12fedc920c0.mp3 +1 -0
  71. package/vendor/pm-vm-dist/dist/media/music/b5e23b90e61aaf37f5de98e9ca81fdbb.mp3 +1 -0
  72. package/vendor/pm-vm-dist/dist/media/music/bc4d077743d9bc06d58065fd764f4019.mp3 +1 -0
  73. package/vendor/pm-vm-dist/dist/media/music/bef29ef069663beb6280b70aac6705de.mp3 +1 -0
  74. package/vendor/pm-vm-dist/dist/media/music/c2ba808baa06dac52c1787790396e54c.mp3 +1 -0
  75. package/vendor/pm-vm-dist/dist/media/music/ca1b5393c07468dc2be541c24c3cc687.mp3 +1 -0
  76. package/vendor/pm-vm-dist/dist/media/music/cad1541317d9ef4443a8c6325c7df3be.mp3 +1 -0
  77. package/vendor/pm-vm-dist/dist/media/music/cde6740cc2c1d45a800e1237cc86f9a1.mp3 +1 -0
  78. package/vendor/pm-vm-dist/dist/media/music/d448a74cb6caebc801e921ecdfd0c5d8.mp3 +1 -0
  79. package/vendor/pm-vm-dist/dist/media/music/d5e7e578ff2d4a9b14c0d51f33a24299.mp3 +1 -0
  80. package/vendor/pm-vm-dist/dist/media/music/df6c9e67211654be18df433951b021b0.mp3 +1 -0
  81. package/vendor/pm-vm-dist/dist/media/music/dfe095471b15f0935cde612b52c91a3c.mp3 +1 -0
  82. package/vendor/pm-vm-dist/dist/media/music/e64996bbc174611c2ad3d04a1434f7a0.mp3 +1 -0
  83. package/vendor/pm-vm-dist/dist/media/music/e9e0e78519e67c9a17563711949e4684.mp3 +1 -0
  84. package/vendor/pm-vm-dist/dist/media/music/eb290db5208d65da146b41d34f22f008.mp3 +1 -0
  85. package/vendor/pm-vm-dist/dist/media/music/ebe739a8e09e50ea00c5f1afacc15d6a.mp3 +1 -0
  86. package/vendor/pm-vm-dist/dist/media/music/ed88e2831000ca0e9dceabb95a346597.mp3 +1 -0
  87. package/vendor/pm-vm-dist/dist/media/music/f059a67cea01cb6e49ba358c05d658c9.mp3 +1 -0
  88. package/vendor/pm-vm-dist/dist/media/music/f3a9a9ae967dd5a330f29aa94d5c4680.mp3 +1 -0
  89. package/vendor/pm-vm-dist/dist/media/music/f7e43cdb9928ae9999bc623f2087ee1a.mp3 +1 -0
  90. package/vendor/pm-vm-dist/dist/media/music/fc4058e26c5069cbf08e741771d1e210.mp3 +1 -0
  91. package/vendor/pm-vm-dist/dist/media/music/fd598784d15d0a4673327e341eb40d3b.mp3 +1 -0
  92. package/vendor/pm-vm-dist/dist/scratch-vm.js +187 -0
  93. package/vendor/pm-vm-dist/dist/static/assets/0043f23189fb3cbf2021a88959961ece.mp3 +0 -0
  94. package/vendor/pm-vm-dist/dist/static/assets/00d2dff9d5ece15a67074374b2027590.mp3 +0 -0
  95. package/vendor/pm-vm-dist/dist/static/assets/02996322134b61a0344527cddc61f9bd.png +0 -0
  96. package/vendor/pm-vm-dist/dist/static/assets/06e9863f95061349f2ba2e0fb0b58ddc.png +0 -0
  97. package/vendor/pm-vm-dist/dist/static/assets/0966e5defb1ae50e569fbbc7ebe7f22a.png +0 -0
  98. package/vendor/pm-vm-dist/dist/static/assets/09995475180591a6c64c8744fbfdf4d8.mp3 +0 -0
  99. package/vendor/pm-vm-dist/dist/static/assets/0b18f02669375ad2ee80bef8d07ea7ec.mp3 +0 -0
  100. package/vendor/pm-vm-dist/dist/static/assets/0dcd27b60c185811b19f59d56eaa12f6.mp3 +0 -0
  101. package/vendor/pm-vm-dist/dist/static/assets/0e8ed599d9875c77d52db36de2b2b33f.mp3 +0 -0
  102. package/vendor/pm-vm-dist/dist/static/assets/103fe904046c35630c600cb122492d78.mp3 +0 -0
  103. package/vendor/pm-vm-dist/dist/static/assets/10af80546707afae5507edb828dd35a0.mp3 +0 -0
  104. package/vendor/pm-vm-dist/dist/static/assets/11c35683a60415bf62ee1a7324a25848.png +0 -0
  105. package/vendor/pm-vm-dist/dist/static/assets/129b99ac19024c35228d855f42fb852b.mp3 +0 -0
  106. package/vendor/pm-vm-dist/dist/static/assets/1980303ffc6f5c17c601a9e5992aea77.mp3 +0 -0
  107. package/vendor/pm-vm-dist/dist/static/assets/1a064f9a752824e781ccf79ab66850ed.mp3 +0 -0
  108. package/vendor/pm-vm-dist/dist/static/assets/1a87a73db1ba0193283680e37b35754e.mp3 +0 -0
  109. package/vendor/pm-vm-dist/dist/static/assets/1c4f445589ea1ce7d954fc601f787f95.svg +58 -0
  110. package/vendor/pm-vm-dist/dist/static/assets/1c8f54c61adf1cee25299833093395f3.png +0 -0
  111. package/vendor/pm-vm-dist/dist/static/assets/1c9630b745711a0b829841e8555d76e4.mp3 +0 -0
  112. package/vendor/pm-vm-dist/dist/static/assets/1d1e22600289d252ebda0b9924bf7d3a.png +0 -0
  113. package/vendor/pm-vm-dist/dist/static/assets/1e7ed7b14b056ebd80f434143846fb4e.mp3 +0 -0
  114. package/vendor/pm-vm-dist/dist/static/assets/2b0cfb89b2c8d1badb03136fcf911c3b.mp3 +0 -0
  115. package/vendor/pm-vm-dist/dist/static/assets/2b7b80de8884cc3cf584aa0deb86a59b.mp3 +0 -0
  116. package/vendor/pm-vm-dist/dist/static/assets/3682b35e452a7f4a5a61167d1632e24a.ttf +0 -0
  117. package/vendor/pm-vm-dist/dist/static/assets/368c582b289e75a3c5299e954c2ad735.ttf +0 -0
  118. package/vendor/pm-vm-dist/dist/static/assets/36ed01ff8cd7eccf19f2606b56f6cd9f.svg +18 -0
  119. package/vendor/pm-vm-dist/dist/static/assets/39563f3b38b1fdb805323b1f69dace54.mp3 +0 -0
  120. package/vendor/pm-vm-dist/dist/static/assets/3fbd690bdae84637e34b8b933a164e3d.mp3 +0 -0
  121. package/vendor/pm-vm-dist/dist/static/assets/4240a79d13ecf2792f825457194c96d9.mp3 +0 -0
  122. package/vendor/pm-vm-dist/dist/static/assets/44c364962f4cb85aef2cdec4f0e01fd4.png +0 -0
  123. package/vendor/pm-vm-dist/dist/static/assets/48d66af328a669a1c84c5dc87a4e9a3b.ttf +0 -0
  124. package/vendor/pm-vm-dist/dist/static/assets/4c5757837d14e9fca439064c62617ba1.png +0 -0
  125. package/vendor/pm-vm-dist/dist/static/assets/4c768843c9fa6593074231cd762b83f8.ttf +0 -0
  126. package/vendor/pm-vm-dist/dist/static/assets/4ec6148233578e86736548eb8b9952e7.png +0 -0
  127. package/vendor/pm-vm-dist/dist/static/assets/51a75dd50afd0bc6022dfc86455be19c.mp3 +0 -0
  128. package/vendor/pm-vm-dist/dist/static/assets/57f0f65052d9dac78ee66b11cc696b6a.mp3 +0 -0
  129. package/vendor/pm-vm-dist/dist/static/assets/5888c4ba6b8a0767f8be5bbc81994397.mp3 +0 -0
  130. package/vendor/pm-vm-dist/dist/static/assets/58d62765bbaa69620eb5bcd2c1cbbebb.png +0 -0
  131. package/vendor/pm-vm-dist/dist/static/assets/5c6533a34175f61a9cab627f671111e1.mp3 +0 -0
  132. package/vendor/pm-vm-dist/dist/static/assets/5e0704b815761d38242f1b961ce9ef69.mp3 +0 -0
  133. package/vendor/pm-vm-dist/dist/static/assets/5e80f6caeb6323606a203fbb248fe18a.png +0 -0
  134. package/vendor/pm-vm-dist/dist/static/assets/5f46c8dc3b8cc2296eb73f013dfce3db.ttf +0 -0
  135. package/vendor/pm-vm-dist/dist/static/assets/5fe2f38d9b66e0a55b49e717fcd5c001.png +0 -0
  136. package/vendor/pm-vm-dist/dist/static/assets/61edf73198617b2326d37f232b8abd6f.otf +0 -0
  137. package/vendor/pm-vm-dist/dist/static/assets/638bc6bde253241454993ae12273970b.png +0 -0
  138. package/vendor/pm-vm-dist/dist/static/assets/64bcfe1f13fce38e2ea26280157ff2ce.ttf +0 -0
  139. package/vendor/pm-vm-dist/dist/static/assets/7337f194b0a727f4be98902487dfb615.mp3 +0 -0
  140. package/vendor/pm-vm-dist/dist/static/assets/74496d9086d97aaeeafb3085e9957668.ttf +0 -0
  141. package/vendor/pm-vm-dist/dist/static/assets/76e8f03f3f2ac00ab16dd4f2afee63c4.png +0 -0
  142. package/vendor/pm-vm-dist/dist/static/assets/78a74345ee778afbd6abe637b4f69efc.mp3 +0 -0
  143. package/vendor/pm-vm-dist/dist/static/assets/7a7fdb40997804d4778e2a12bd7599f7.mp3 +0 -0
  144. package/vendor/pm-vm-dist/dist/static/assets/7c5a16e0627c64fd62ec597bb72b879d.mp3 +0 -0
  145. package/vendor/pm-vm-dist/dist/static/assets/827c57667d965940792a82d82345e075.png +0 -0
  146. package/vendor/pm-vm-dist/dist/static/assets/83857346b7c2e6a351c69377e82a3606.mp3 +0 -0
  147. package/vendor/pm-vm-dist/dist/static/assets/83f2d48d0c7d1e6be8a509d4d2c6d0e5.mp3 +0 -0
  148. package/vendor/pm-vm-dist/dist/static/assets/87a3a62c0627e925b92d3ede2f62b04f.ttf +0 -0
  149. package/vendor/pm-vm-dist/dist/static/assets/8996930d8e83f63cc3341172205df460.otf +0 -0
  150. package/vendor/pm-vm-dist/dist/static/assets/8bad9bd9d9a0d53b4a0f4e69bf02764a.png +0 -0
  151. package/vendor/pm-vm-dist/dist/static/assets/8c0c02f16572b730d422f96a3ce53e7f.mp3 +0 -0
  152. package/vendor/pm-vm-dist/dist/static/assets/8ca9997e9ed7534a8d0f349a6125bd24.mp3 +0 -0
  153. package/vendor/pm-vm-dist/dist/static/assets/8d83d454d2c42614a203322393c23a5b.ttf +0 -0
  154. package/vendor/pm-vm-dist/dist/static/assets/8f3259bb74d811177e8e65dcbea39ed4.png +0 -0
  155. package/vendor/pm-vm-dist/dist/static/assets/8fdeee6df0b53509b7f9d623cab482b3.svg +1 -0
  156. package/vendor/pm-vm-dist/dist/static/assets/90eaf6469dbf3a808a3d9739cc77358d.mp3 +0 -0
  157. package/vendor/pm-vm-dist/dist/static/assets/942d5ca1a892e7dc5156d0ae7b438c1b.png +0 -0
  158. package/vendor/pm-vm-dist/dist/static/assets/94b3a496d095dd3b3cfbaabb84a190fd.png +0 -0
  159. package/vendor/pm-vm-dist/dist/static/assets/961bfad11616b6f5c951c58499cf267a.mp3 +0 -0
  160. package/vendor/pm-vm-dist/dist/static/assets/97aa37df83e2c5c2effa09d6622cd3a7.png +0 -0
  161. package/vendor/pm-vm-dist/dist/static/assets/9ad12a9de73f49fb645902987027eb2b.mp3 +0 -0
  162. package/vendor/pm-vm-dist/dist/static/assets/9ec3449723269a806ec6eaba0f8f6cef.ttf +0 -0
  163. package/vendor/pm-vm-dist/dist/static/assets/9f8e3a1db63c7a8fd976beca7e3546c3.mp3 +0 -0
  164. package/vendor/pm-vm-dist/dist/static/assets/a02821bc5d19d616d43d55ac10779101.png +0 -0
  165. package/vendor/pm-vm-dist/dist/static/assets/a05bbf6359407ad7950e115edab178d9.mp3 +0 -0
  166. package/vendor/pm-vm-dist/dist/static/assets/a0b130dbafdc7958b73d162239053173.mp3 +0 -0
  167. package/vendor/pm-vm-dist/dist/static/assets/a61fc8393abec3c0d1e813fc01f252ee.mp3 +0 -0
  168. package/vendor/pm-vm-dist/dist/static/assets/a7c08a472cc8b1c4e0c45ae9b030b414.mp3 +0 -0
  169. package/vendor/pm-vm-dist/dist/static/assets/b52b83f63e8482255d46f40ea454fa01.mp3 +0 -0
  170. package/vendor/pm-vm-dist/dist/static/assets/b6a8e4a96ffcd83154ad2c5cbcb52751.mp3 +0 -0
  171. package/vendor/pm-vm-dist/dist/static/assets/b700cf6f9c368ce49851d287ddd3b63c.mp3 +0 -0
  172. package/vendor/pm-vm-dist/dist/static/assets/bacc13779ca01580b89f39dd2118c22a.mp3 +0 -0
  173. package/vendor/pm-vm-dist/dist/static/assets/bb59f04e7f0aabe8e142ac1607a03dd6.otf +0 -0
  174. package/vendor/pm-vm-dist/dist/static/assets/bd16b79cacee7f23835efba88ed4aa67.png +0 -0
  175. package/vendor/pm-vm-dist/dist/static/assets/c36dd943607003a3229aca77b49d32de.mp3 +0 -0
  176. package/vendor/pm-vm-dist/dist/static/assets/c44ce3bfa465281e765f197d6c3ee7ea.mp3 +0 -0
  177. package/vendor/pm-vm-dist/dist/static/assets/c60f3f06c5fb4d49df7236aeb2f31a9d.png +0 -0
  178. package/vendor/pm-vm-dist/dist/static/assets/d434438e5d193118e4aad3a846e1c7ba.mp3 +0 -0
  179. package/vendor/pm-vm-dist/dist/static/assets/d4b3ce804680eb7d58f6accc7436e807.mp3 +0 -0
  180. package/vendor/pm-vm-dist/dist/static/assets/d6214ab9abf22f5d5dee060cb72f637f.png +0 -0
  181. package/vendor/pm-vm-dist/dist/static/assets/d72b98474fc83d6268886779339e242e.mp3 +0 -0
  182. package/vendor/pm-vm-dist/dist/static/assets/da1a6ebcd442e9d867b31fb21ce6a289.mp3 +0 -0
  183. package/vendor/pm-vm-dist/dist/static/assets/da3126c6a70c4ef84192c33f99996354.mp3 +0 -0
  184. package/vendor/pm-vm-dist/dist/static/assets/e27f7cdf42df8b768345ae83fecffe4b.mp3 +0 -0
  185. package/vendor/pm-vm-dist/dist/static/assets/e6180253665b8248f685c6d96881f75b.mp3 +0 -0
  186. package/vendor/pm-vm-dist/dist/static/assets/eb64e706eb7a3b324ba50d3279a980b1.ttf +0 -0
  187. package/vendor/pm-vm-dist/dist/static/assets/ecb1cbda3b9c4f29ab971734d97e3faf.mp3 +0 -0
  188. package/vendor/pm-vm-dist/dist/static/assets/ed2a96fbdb476efe419246982e357121.mp3 +0 -0
  189. package/vendor/pm-vm-dist/dist/static/assets/edd84717633475a8d3db34087123314a.png +0 -0
  190. package/vendor/pm-vm-dist/dist/static/assets/f04f7b0f673ced51710157ce16e49276.png +0 -0
  191. package/vendor/pm-vm-dist/dist/static/assets/f28b1ecd6fccd6218a837c14b1109da7.mp3 +0 -0
  192. package/vendor/pm-vm-dist/dist/static/assets/f382f43d667b784d1bcd8cfe7aa6fff0.mp3 +0 -0
  193. package/vendor/pm-vm-dist/dist/static/assets/f8d8b9d9f1aa45900b786980418219b1.mp3 +0 -0
  194. package/vendor/pm-vm-dist/dist/static/assets/f974293a11768662b53444715e3439ed.png +0 -0
  195. package/vendor/pm-vm-dist/dist/static/assets/f9868658d8483a6dbcd600e0226dfeef.mp3 +0 -0
  196. package/vendor/pm-vm-dist/dist/static/assets/fad59dd220562492e6722aa8a97f9369.mp3 +0 -0
  197. package/vendor/pm-vm-dist/dist/static/assets/fc383956e315b7ee2bd8dd09de311701.mp3 +0 -0
  198. package/vendor/pm-vm-dist/package.json +3 -0
@@ -0,0 +1,1457 @@
1
+ /**
2
+ * Test runner.
3
+ *
4
+ * Discovers test files, builds the project once, then runs each test in a
5
+ * fresh headless PenguinMod VM. Actions drive the runtime; assertions read
6
+ * project state through explicit `state.*` queries. The runner never prints:
7
+ * it emits structured events for the terminal, JSON, JUnit, and editor
8
+ * reporters.
9
+ */
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import { build } from "../build.js";
13
+ import { loadProject } from "../vm.js";
14
+ import { loadConfigFrom, DEFAULT_TESTS } from "../config.js";
15
+ import { loadExtensions } from "../extensions.js";
16
+ import { loadFeatureExtensions } from "../features.js";
17
+ import { loadVendorExtensions } from "../vendor.js";
18
+ import { productVersion } from "../brand.js";
19
+ import { discoverTests } from "./discover.js";
20
+ import { parseTests } from "./parse.js";
21
+
22
+ /** Exit codes promised by `fx test`. */
23
+ export const TEST_EXIT = {
24
+ OK: 0,
25
+ FAILED: 1,
26
+ ERROR: 2,
27
+ INTERRUPTED: 3,
28
+ USAGE: 4,
29
+ };
30
+
31
+ /** A configuration or test-language problem. */
32
+ export class TestError extends Error {
33
+ constructor(message, options = {}) {
34
+ super(message, options.cause ? { cause: options.cause } : undefined);
35
+ this.name = "TestError";
36
+ this.exitCode = options.exitCode ?? TEST_EXIT.ERROR;
37
+ this.hint = options.hint;
38
+ this.diagnostics = options.diagnostics ?? [];
39
+ }
40
+ }
41
+
42
+ class AssertionFailure extends Error {
43
+ constructor(details) {
44
+ super(details.message);
45
+ this.name = details.name ?? "AssertionError";
46
+ this.details = { name: this.name, ...details };
47
+ }
48
+ }
49
+
50
+ class TimeoutFailure extends Error {
51
+ constructor(timeout) {
52
+ super(`Test timed out after ${timeout}ms`);
53
+ this.name = "TimeoutError";
54
+ }
55
+ }
56
+
57
+ class ReturnSignal {
58
+ constructor(value) {
59
+ this.value = value;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Deterministic pseudo-random source for a run.
65
+ * @param {number} seed
66
+ * @returns {() => number} values in `[0, 1)`
67
+ */
68
+ function mulberry32(seed) {
69
+ let state = seed | 0;
70
+ return () => {
71
+ state = (state + 0x6d2b79f5) | 0;
72
+ let t = Math.imul(state ^ (state >>> 15), 1 | state);
73
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
74
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
75
+ };
76
+ }
77
+
78
+ /** Source location of an AST node, defaulting unknown fields to null. */
79
+ function locOf(node) {
80
+ const loc = node?.loc ?? {};
81
+ return {
82
+ file: loc.file ?? null,
83
+ line: loc.line ?? null,
84
+ column: loc.column ?? null,
85
+ };
86
+ }
87
+
88
+ /** Whether a value is, or is entirely made of, a finite number. */
89
+ function isNumeric(value) {
90
+ if (typeof value === "number") return Number.isFinite(value);
91
+ if (typeof value === "string") {
92
+ const trimmed = value.trim();
93
+ if (trimmed === "") return false;
94
+ const number = Number(trimmed);
95
+ return !Number.isNaN(number);
96
+ }
97
+ return false;
98
+ }
99
+
100
+ /** Scratch-style truthiness: `"0"` and `""` are false. */
101
+ function truthy(value) {
102
+ if (typeof value === "boolean") return value;
103
+ if (typeof value === "number") return value !== 0;
104
+ if (typeof value === "string")
105
+ return isNumeric(value) ? Number(value) !== 0 : value !== "";
106
+ if (Array.isArray(value)) return value.length > 0;
107
+ return Boolean(value);
108
+ }
109
+
110
+ /** Compare like the VM's `Cast.compare`: numeric when both sides are. */
111
+ function compareValues(left, right) {
112
+ if (isNumeric(left) && isNumeric(right)) {
113
+ const a = Number(left);
114
+ const b = Number(right);
115
+ return a < b ? -1 : a > b ? 1 : 0;
116
+ }
117
+ const a = String(left).toLowerCase();
118
+ const b = String(right).toLowerCase();
119
+ return a < b ? -1 : a > b ? 1 : 0;
120
+ }
121
+
122
+ /** Render a value for an assertion message. */
123
+ function formatValue(value) {
124
+ if (typeof value === "string") return JSON.stringify(value);
125
+ if (value === undefined) return "undefined";
126
+ if (typeof value === "object" && value !== null) return JSON.stringify(value);
127
+ return String(value);
128
+ }
129
+
130
+ /** Find a project variable by name and variable type, stage first. */
131
+ function findVariable(vm, name, type) {
132
+ const stage = vm.runtime.getTargetForStage?.();
133
+ const targets = [stage, ...vm.runtime.targets.filter((t) => !t.isStage)];
134
+ for (const target of targets) {
135
+ if (!target) continue;
136
+ for (const variable of Object.values(target.variables ?? {})) {
137
+ if (variable.name === name && (variable.type ?? "") === type)
138
+ return variable;
139
+ }
140
+ }
141
+ return null;
142
+ }
143
+
144
+ /** Capture the mutable state `reset()` restores between tests. */
145
+ function snapshotVm(vm) {
146
+ return vm.runtime.targets.map((target) => ({
147
+ id: target.id,
148
+ variables: Object.fromEntries(
149
+ Object.entries(target.variables ?? {}).map(([id, variable]) => [
150
+ id,
151
+ Array.isArray(variable.value) ? [...variable.value] : variable.value,
152
+ ]),
153
+ ),
154
+ x: target.x,
155
+ y: target.y,
156
+ direction: target.direction,
157
+ visible: target.visible,
158
+ size: target.size,
159
+ currentCostume: target.currentCostume,
160
+ }));
161
+ }
162
+
163
+ /** Restore a snapshot taken by {@link snapshotVm}. */
164
+ function restoreVm(vm, snapshot) {
165
+ vm.stopAll();
166
+ vm.runtime.threads = [];
167
+ for (const saved of snapshot) {
168
+ const target = vm.runtime.getTargetById(saved.id);
169
+ if (!target) continue;
170
+ for (const [id, value] of Object.entries(saved.variables)) {
171
+ if (target.variables?.[id])
172
+ target.variables[id].value = Array.isArray(value) ? [...value] : value;
173
+ }
174
+ target.x = saved.x;
175
+ target.y = saved.y;
176
+ target.direction = saved.direction;
177
+ target.visible = saved.visible;
178
+ target.size = saved.size;
179
+ target.currentCostume = saved.currentCostume;
180
+ }
181
+ }
182
+
183
+ // ------------------------------------------------------------------ interpreter
184
+
185
+ /** Execution state for one test: bindings, VM, clocks and captures. */
186
+ function createScope({ vm, config, seed, capture, deadline, warnings }) {
187
+ return {
188
+ vm,
189
+ config,
190
+ seed,
191
+ capture,
192
+ deadline,
193
+ warnings,
194
+ progress: { ticks: 0 },
195
+ vars: new Map(),
196
+ functions: new Map(),
197
+ broadcasts: new Set(),
198
+ };
199
+ }
200
+
201
+ /**
202
+ * Abort the test once its wall-clock budget is spent.
203
+ *
204
+ * This is the only real-time mechanism in a test: `durationMs` is measurement,
205
+ * while `step()` and everything it advances use simulated time.
206
+ */
207
+ function checkDeadline(scope) {
208
+ if (performance.now() > scope.deadline)
209
+ throw new TimeoutFailure(scope.config.timeout);
210
+ }
211
+
212
+ /** Scope for a function call or block: new cells, shared outer bindings. */
213
+ function childScope(scope) {
214
+ // Copy bindings while sharing their cells; declarations and parameters get
215
+ // new cells, while assignments to existing test variables reach the caller.
216
+ return { ...scope, vars: new Map(scope.vars) };
217
+ }
218
+
219
+ /** Evaluate a test-language expression. */
220
+ function evaluate(node, scope) {
221
+ switch (node.type) {
222
+ case "Number":
223
+ case "String":
224
+ case "Boolean":
225
+ return node.value;
226
+ case "ArrayLiteral":
227
+ return node.items.map((item) => evaluate(item, scope));
228
+ case "Identifier": {
229
+ if (scope.vars.has(node.name)) return scope.vars.get(node.name).value;
230
+ throw new TestError(
231
+ `Unknown name ${JSON.stringify(node.name)} in test expression`,
232
+ { diagnostics: [locOf(node)] },
233
+ );
234
+ }
235
+ case "Member": {
236
+ const object = evaluate(node.object, scope);
237
+ if (object == null || !(node.property in Object(object)))
238
+ throw new TestError(
239
+ `Value has no property ${JSON.stringify(node.property)}`,
240
+ { diagnostics: [locOf(node)] },
241
+ );
242
+ return object[node.property];
243
+ }
244
+ case "Unary": {
245
+ const value = evaluate(node.operand, scope);
246
+ return node.op === "not" ? !truthy(value) : -Number(value);
247
+ }
248
+ case "Binary":
249
+ return evaluateBinary(node, scope);
250
+ case "Call":
251
+ return evaluateCall(node, scope);
252
+ default:
253
+ throw new TestError(`Unsupported expression ${node.type} in a test`, {
254
+ diagnostics: [locOf(node)],
255
+ });
256
+ }
257
+ }
258
+
259
+ /** Evaluate a binary operator with short-circuiting for `and`/`or`. */
260
+ function evaluateBinary(node, scope) {
261
+ if (node.op === "and")
262
+ return (
263
+ truthy(evaluate(node.left, scope)) && truthy(evaluate(node.right, scope))
264
+ );
265
+ if (node.op === "or")
266
+ return (
267
+ truthy(evaluate(node.left, scope)) || truthy(evaluate(node.right, scope))
268
+ );
269
+
270
+ const left = evaluate(node.left, scope);
271
+ const right = evaluate(node.right, scope);
272
+ switch (node.op) {
273
+ case "==":
274
+ return compareValues(left, right) === 0;
275
+ case "!=":
276
+ return compareValues(left, right) !== 0;
277
+ case "<":
278
+ return compareValues(left, right) < 0;
279
+ case "<=":
280
+ return compareValues(left, right) <= 0;
281
+ case ">":
282
+ return compareValues(left, right) > 0;
283
+ case ">=":
284
+ return compareValues(left, right) >= 0;
285
+ case "+":
286
+ return isNumeric(left) && isNumeric(right)
287
+ ? Number(left) + Number(right)
288
+ : String(left) + String(right);
289
+ case "-":
290
+ return Number(left) - Number(right);
291
+ case "*":
292
+ return Number(left) * Number(right);
293
+ case "/":
294
+ return Number(right) === 0 ? Infinity : Number(left) / Number(right);
295
+ case "%": {
296
+ const divisor = Number(right);
297
+ if (divisor === 0) return NaN;
298
+ const result = Number(left) % divisor;
299
+ return result / divisor < 0 ? result + divisor : result;
300
+ }
301
+ default:
302
+ throw new TestError(`Unsupported operator ${node.op} in a test`, {
303
+ diagnostics: [locOf(node)],
304
+ });
305
+ }
306
+ }
307
+
308
+ /** Read a required argument from a call, failing with its location. */
309
+ function argument(node, index) {
310
+ const value = node.args[index];
311
+ if (value === undefined)
312
+ throw new TestError(`Missing argument ${index + 1} for ${node.name}`, {
313
+ diagnostics: [locOf(node)],
314
+ });
315
+ return value;
316
+ }
317
+
318
+ /** Evaluate a test helper call, `state.*` query or project block call. */
319
+ function evaluateCall(node, scope) {
320
+ const segments = node.segments ?? String(node.name).split(".");
321
+ if (segments[0] === "state")
322
+ return stateQuery(segments.slice(1), node, scope);
323
+
324
+ const fn = scope.functions.get(node.name);
325
+ if (!fn) return callProjectProcedure(node, scope);
326
+ const local = childScope(scope);
327
+ fn.params.forEach((param, index) => {
328
+ local.vars.set(param.name, {
329
+ value: node.args[index] ? evaluate(node.args[index], scope) : 0,
330
+ });
331
+ });
332
+ try {
333
+ executeStatements(fn.body, local);
334
+ } catch (signal) {
335
+ if (signal instanceof ReturnSignal) return signal.value;
336
+ throw signal;
337
+ }
338
+ return 0;
339
+ }
340
+
341
+ /** Stage first, then sprites: where a project block can be declared. */
342
+ function procedureTargets(vm) {
343
+ const stage = vm.runtime.getTargetForStage?.();
344
+ return [
345
+ stage,
346
+ ...vm.runtime.targets.filter((target) => !target.isStage),
347
+ ].filter(Boolean);
348
+ }
349
+
350
+ /**
351
+ * Input block id. Compiled JSON stores `[type, id, ...]`; a VM-loaded project
352
+ * stores `{name, block, shadow}`.
353
+ */
354
+ function inputBlockId(input) {
355
+ if (Array.isArray(input)) return input[1] ?? null;
356
+ if (input && typeof input === "object")
357
+ return input.block ?? input.shadow ?? null;
358
+ return null;
359
+ }
360
+
361
+ /** A text primitive block in the shape a VM-loaded project uses. */
362
+ function textBlock(id, value, parent) {
363
+ return {
364
+ id,
365
+ opcode: "text",
366
+ next: null,
367
+ parent,
368
+ inputs: {},
369
+ fields: { TEXT: { name: "TEXT", value: String(value ?? "") } },
370
+ shadow: true,
371
+ topLevel: false,
372
+ };
373
+ }
374
+
375
+ /** Find a custom block definition on one target by name and argument count. */
376
+ function findProcedure(target, name, arity) {
377
+ const blocks = target?.blocks?._blocks ?? {};
378
+ for (const block of Object.values(blocks)) {
379
+ if (
380
+ block.opcode !== "procedures_definition" &&
381
+ block.opcode !== "procedures_definition_return"
382
+ )
383
+ continue;
384
+ const protoId = inputBlockId(block.inputs?.custom_block);
385
+ const proto = typeof protoId === "string" ? blocks[protoId] : null;
386
+ const mutation = proto?.mutation;
387
+ if (!mutation) continue;
388
+ let argIds;
389
+ try {
390
+ argIds = JSON.parse(mutation.argumentids ?? "[]");
391
+ } catch {
392
+ argIds = [];
393
+ }
394
+ if (argIds.length !== arity) continue;
395
+ const proccode = String(mutation.proccode ?? "");
396
+ if (proccode !== name && !proccode.startsWith(`${name} `)) continue;
397
+ return {
398
+ proccode,
399
+ argIds,
400
+ returns:
401
+ block.opcode === "procedures_definition_return" ||
402
+ mutation.returns === "true" ||
403
+ mutation.returns === true,
404
+ };
405
+ }
406
+ return null;
407
+ }
408
+
409
+ /** Names of the project's custom blocks, for error messages. */
410
+ function availableProcedures(vm) {
411
+ const names = [];
412
+ for (const target of procedureTargets(vm)) {
413
+ const blocks = target.blocks?._blocks ?? {};
414
+ for (const block of Object.values(blocks)) {
415
+ if (
416
+ block.opcode !== "procedures_definition" &&
417
+ block.opcode !== "procedures_definition_return"
418
+ )
419
+ continue;
420
+ const protoId = inputBlockId(block.inputs?.custom_block);
421
+ const proto = typeof protoId === "string" ? blocks[protoId] : null;
422
+ const proccode = proto?.mutation?.proccode;
423
+ if (!proccode) continue;
424
+ names.push(
425
+ target.isStage ? String(proccode) : `${target.getName()}.${proccode}`,
426
+ );
427
+ }
428
+ }
429
+ return [...new Set(names)].sort();
430
+ }
431
+
432
+ /**
433
+ * Resolve `score()` or `Player.score()` to a target and its definition.
434
+ * A bare name must match exactly one target. Tests do not receive implicit
435
+ * receivers, so ambiguity is an error rather than a guess.
436
+ */
437
+ function resolveProcedure(node, scope) {
438
+ const segments = node.segments ?? String(node.name).split(".");
439
+ const name = segments.length > 1 ? segments.slice(1).join(".") : segments[0];
440
+ const arity = node.args.length;
441
+
442
+ if (segments.length > 1) {
443
+ const wanted = segments[0];
444
+ const target = procedureTargets(scope.vm).find(
445
+ (candidate) =>
446
+ (candidate.isStage && /^stage$/i.test(wanted)) ||
447
+ candidate.getName?.() === wanted,
448
+ );
449
+ if (!target)
450
+ throw new TestError(
451
+ `No sprite named ${JSON.stringify(wanted)} in the project`,
452
+ { diagnostics: [locOf(node)] },
453
+ );
454
+ const procedure = findProcedure(target, name, arity);
455
+ if (!procedure)
456
+ throw new TestError(
457
+ `No block named ${JSON.stringify(name)} with ${arity} argument(s) in ${wanted}`,
458
+ { diagnostics: [locOf(node)] },
459
+ );
460
+ return { target, procedure };
461
+ }
462
+
463
+ const found = [];
464
+ for (const target of procedureTargets(scope.vm)) {
465
+ const procedure = findProcedure(target, name, arity);
466
+ if (procedure) found.push({ target, procedure });
467
+ }
468
+ if (found.length === 1) return found[0];
469
+ if (found.length > 1) {
470
+ const owners = found.map(({ target }) =>
471
+ target.isStage ? "Stage" : target.getName(),
472
+ );
473
+ throw new TestError(
474
+ `Block ${JSON.stringify(name)} is declared by more than one target (${owners.join(
475
+ ", ",
476
+ )}); qualify it with the sprite name, as in ${owners[0]}.${name}(...)`,
477
+ { diagnostics: [locOf(node)] },
478
+ );
479
+ }
480
+
481
+ const available = availableProcedures(scope.vm);
482
+ throw new TestError(
483
+ `Unknown test action or project block ${JSON.stringify(node.name)}` +
484
+ (available.length ? `. Available blocks: ${available.join(", ")}` : ""),
485
+ { diagnostics: [locOf(node)] },
486
+ );
487
+ }
488
+
489
+ /**
490
+ * Call a project custom block and wait for it to finish.
491
+ *
492
+ * A returning block is wrapped in a temporary `set variable` script so its
493
+ * value can be read from the VM, then the synthetic blocks and variable are
494
+ * removed again.
495
+ */
496
+ function callProjectProcedure(node, scope) {
497
+ const { target, procedure } = resolveProcedure(node, scope);
498
+ const { vm } = scope;
499
+ const blocks = target.blocks;
500
+ if (!blocks?._blocks)
501
+ throw new TestError(
502
+ "The project target has no blocks to call; rebuild the project",
503
+ { diagnostics: [locOf(node)] },
504
+ );
505
+ if (typeof vm.runtime._pushThread !== "function")
506
+ throw new TestError(
507
+ "This PenguinMod VM build cannot run project blocks from tests",
508
+ { diagnostics: [locOf(node)] },
509
+ );
510
+
511
+ const values = node.args.map((arg) => evaluate(arg, scope));
512
+ scope.progress.procedureCalls = (scope.progress.procedureCalls ?? 0) + 1;
513
+ const stamp = `${String(target.id ?? "target").replace(/[^A-Za-z0-9_]/g, "_")}_fxtest${scope.progress.procedureCalls}_${scope.progress.ticks}`;
514
+ const callId = `${stamp}_call`;
515
+ const synthetic = [callId];
516
+ const inputs = {};
517
+ procedure.argIds.forEach((argId, index) => {
518
+ const argBlockId = `${stamp}_arg${index}`;
519
+ synthetic.push(argBlockId);
520
+ blocks._blocks[argBlockId] = textBlock(argBlockId, values[index], callId);
521
+ inputs[argId] = { name: argId, block: argBlockId, shadow: argBlockId };
522
+ });
523
+ blocks._blocks[callId] = {
524
+ id: callId,
525
+ opcode: "procedures_call",
526
+ next: null,
527
+ parent: null,
528
+ inputs,
529
+ fields: {},
530
+ shadow: false,
531
+ topLevel: true,
532
+ x: 0,
533
+ y: 0,
534
+ mutation: {
535
+ tagName: "mutation",
536
+ children: [],
537
+ proccode: procedure.proccode,
538
+ argumentids: JSON.stringify(procedure.argIds),
539
+ warp: "false",
540
+ ...(procedure.returns ? { returns: "true" } : {}),
541
+ },
542
+ };
543
+
544
+ let setId = null;
545
+ let varId = null;
546
+ if (procedure.returns) {
547
+ varId = `${stamp}_var`;
548
+ const varName = `__fxTestReturn${scope.progress.procedureCalls}`;
549
+ target.variables[varId] = {
550
+ id: varId,
551
+ name: varName,
552
+ type: "",
553
+ value: 0,
554
+ isCloud: false,
555
+ };
556
+ setId = `${stamp}_set`;
557
+ const valueShadowId = `${stamp}_value`;
558
+ synthetic.push(setId, valueShadowId);
559
+ blocks._blocks[valueShadowId] = textBlock(valueShadowId, "", setId);
560
+ blocks._blocks[setId] = {
561
+ id: setId,
562
+ opcode: "data_setvariableto",
563
+ next: null,
564
+ parent: null,
565
+ inputs: {
566
+ VALUE: { name: "VALUE", block: callId, shadow: valueShadowId },
567
+ },
568
+ fields: {
569
+ VARIABLE: {
570
+ name: "VARIABLE",
571
+ value: varName,
572
+ id: varId,
573
+ variableType: "",
574
+ },
575
+ },
576
+ shadow: false,
577
+ topLevel: true,
578
+ x: 0,
579
+ y: 0,
580
+ };
581
+ blocks._blocks[callId].parent = setId;
582
+ }
583
+
584
+ try {
585
+ const thread = vm.runtime._pushThread(setId ?? callId, target);
586
+ if (!thread)
587
+ throw new TestError(
588
+ `Could not start ${JSON.stringify(node.name)} in the VM`,
589
+ { diagnostics: [locOf(node)] },
590
+ );
591
+ while (vm.runtime.threads.includes(thread)) stepTicks(scope, 1);
592
+ if (!procedure.returns) return 0;
593
+ return target.variables[varId]?.value ?? 0;
594
+ } finally {
595
+ for (const id of synthetic) delete blocks._blocks[id];
596
+ if (varId) delete target.variables[varId];
597
+ }
598
+ }
599
+
600
+ /** Resolve a `state.*` assertion query against the VM. */
601
+ function stateQuery(segments, node, scope) {
602
+ const [query, property] = segments;
603
+ const args = node.args.map((arg) => evaluate(arg, scope));
604
+ const { vm } = scope;
605
+
606
+ if (segments.length > 2)
607
+ throw new TestError(`Unknown state query state.${segments.join(".")}`, {
608
+ diagnostics: [locOf(node)],
609
+ });
610
+
611
+ switch (query) {
612
+ case "variable": {
613
+ const variable = findVariable(vm, String(args[0]), "");
614
+ if (!variable)
615
+ throw new TestError(
616
+ `No variable named ${JSON.stringify(String(args[0]))} in the project`,
617
+ { diagnostics: [locOf(node)] },
618
+ );
619
+ return variable.value;
620
+ }
621
+ case "list": {
622
+ const variable = findVariable(vm, String(args[0]), "list");
623
+ if (!variable)
624
+ throw new TestError(
625
+ `No list named ${JSON.stringify(String(args[0]))} in the project`,
626
+ { diagnostics: [locOf(node)] },
627
+ );
628
+ const items = Array.isArray(variable.value) ? [...variable.value] : [];
629
+ if (!property)
630
+ return {
631
+ length: items.length,
632
+ items,
633
+ itemAt: (index) => items[Number(index) - 1],
634
+ };
635
+ if (property === "length") return items.length;
636
+ if (property === "items") return items;
637
+ throw new TestError(`Unknown list property ${JSON.stringify(property)}`, {
638
+ diagnostics: [locOf(node)],
639
+ });
640
+ }
641
+ case "sprite": {
642
+ const name = String(args[0]);
643
+ const target = vm.runtime.getSpriteTargetByName(name);
644
+ if (!target)
645
+ throw new TestError(
646
+ `No sprite named ${JSON.stringify(name)} in the project`,
647
+ {
648
+ diagnostics: [locOf(node)],
649
+ },
650
+ );
651
+ const sprite = {
652
+ name,
653
+ x: target.x,
654
+ y: target.y,
655
+ direction: target.direction,
656
+ visible: target.visible,
657
+ size: target.size,
658
+ costume: target.getCostumes?.()[target.currentCostume]?.name ?? null,
659
+ };
660
+ if (!property) return sprite;
661
+ if (!(property in sprite))
662
+ throw new TestError(
663
+ `Unknown sprite property ${JSON.stringify(property)}`,
664
+ {
665
+ diagnostics: [locOf(node)],
666
+ },
667
+ );
668
+ return sprite[property];
669
+ }
670
+ case "broadcasted":
671
+ return scope.broadcasts.has(String(args[0]));
672
+ case "errors":
673
+ return [...scope.capture.errors];
674
+ case "ticks":
675
+ return scope.progress.ticks;
676
+ default:
677
+ throw new TestError(`Unknown state query state.${segments.join(".")}`, {
678
+ diagnostics: [locOf(node)],
679
+ });
680
+ }
681
+ }
682
+
683
+ /** Run a list of test statements in order. */
684
+ function executeStatements(statements, scope) {
685
+ for (const statement of statements) {
686
+ checkDeadline(scope);
687
+ executeStatement(statement, scope);
688
+ }
689
+ }
690
+
691
+ /** Run one statement node. */
692
+ function executeStatement(node, scope) {
693
+ switch (node.type) {
694
+ case "VarDecl":
695
+ scope.vars.set(node.name, {
696
+ value: node.init ? evaluate(node.init, scope) : 0,
697
+ });
698
+ return;
699
+ case "FunctionDecl":
700
+ scope.functions.set(node.name, node);
701
+ return;
702
+ case "Assign": {
703
+ if (!scope.vars.has(node.name))
704
+ throw new TestError(
705
+ `Unknown variable ${JSON.stringify(node.name)} in test`,
706
+ { diagnostics: [locOf(node)] },
707
+ );
708
+ const previous = scope.vars.get(node.name).value;
709
+ const value = evaluate(node.value, scope);
710
+ const next =
711
+ node.op === "="
712
+ ? value
713
+ : node.op === "+="
714
+ ? isNumeric(previous) && isNumeric(value)
715
+ ? Number(previous) + Number(value)
716
+ : String(previous) + String(value)
717
+ : node.op === "-="
718
+ ? Number(previous) - Number(value)
719
+ : node.op === "*="
720
+ ? Number(previous) * Number(value)
721
+ : Number(previous) / Number(value);
722
+ scope.vars.get(node.name).value = next;
723
+ return;
724
+ }
725
+ case "If":
726
+ if (truthy(evaluate(node.cond, scope)))
727
+ executeStatements(node.then, scope);
728
+ else if (node.else) executeStatements(node.else, scope);
729
+ return;
730
+ case "Repeat": {
731
+ const count = Math.round(Number(evaluate(node.count, scope)));
732
+ for (let index = 0; index < count; index += 1) {
733
+ checkDeadline(scope);
734
+ executeStatements(node.body, scope);
735
+ }
736
+ return;
737
+ }
738
+ case "While":
739
+ while (truthy(evaluate(node.cond, scope))) {
740
+ checkDeadline(scope);
741
+ executeStatements(node.body, scope);
742
+ }
743
+ return;
744
+ case "Forever":
745
+ for (;;) {
746
+ checkDeadline(scope);
747
+ executeStatements(node.body, scope);
748
+ }
749
+ case "Return":
750
+ throw new ReturnSignal(node.value ? evaluate(node.value, scope) : 0);
751
+ case "Expect":
752
+ assertExpectation(node, scope);
753
+ return;
754
+ case "BlockCall":
755
+ executeAction(node, scope);
756
+ return;
757
+ default:
758
+ throw new TestError(`Unsupported statement ${node.type} in a test`, {
759
+ diagnostics: [locOf(node)],
760
+ });
761
+ }
762
+ }
763
+
764
+ const COMPARISONS = {
765
+ "==": "to equal",
766
+ "!=": "not to equal",
767
+ ">": "to be greater than",
768
+ ">=": "to be at least",
769
+ "<": "to be less than",
770
+ "<=": "to be at most",
771
+ };
772
+
773
+ /** Throw a detailed `AssertionFailure` when an `expect` is not truthy. */
774
+ function assertExpectation(node, scope) {
775
+ if (truthy(evaluate(node.value, scope))) return;
776
+
777
+ let message = "expected a truthy value";
778
+ let expected;
779
+ let received;
780
+ if (node.value.type === "Binary" && COMPARISONS[node.value.op]) {
781
+ received = evaluate(node.value.left, scope);
782
+ expected = evaluate(node.value.right, scope);
783
+ message = `expected ${formatValue(received)} ${COMPARISONS[node.value.op]} ${formatValue(expected)}`;
784
+ }
785
+ if (node.message) message = `${node.message}: ${message}`;
786
+
787
+ throw new AssertionFailure({
788
+ message,
789
+ expected,
790
+ received,
791
+ ...locOf(node),
792
+ });
793
+ }
794
+
795
+ /**
796
+ * Advance simulated time by `count` frames.
797
+ *
798
+ * `runtime._step()` reads `currentMSecs`, so advancing it directly keeps
799
+ * waits, timers and the tick counter deterministic and independent of the
800
+ * real clock. Nothing here sleeps or waits on wall-clock time; the deadline
801
+ * check is the only place real time can stop a run.
802
+ */
803
+ function stepTicks(scope, count) {
804
+ for (let index = 0; index < count; index += 1) {
805
+ checkDeadline(scope);
806
+ try {
807
+ scope.vm.runtime.currentMSecs += 1000 / scope.config.ticksPerSecond;
808
+ scope.vm.runtime._step();
809
+ } catch (error) {
810
+ scope.capture.errors.push(error?.message ?? String(error));
811
+ if (scope.config.failOnRuntimeError) throw error;
812
+ }
813
+ scope.progress.ticks += 1;
814
+ }
815
+ }
816
+
817
+ /** Run a built-in test action, falling back to a project block call. */
818
+ function executeAction(node, scope) {
819
+ const name = (node.segments ?? [node.name]).join(".");
820
+ const arg = (index) => evaluate(argument(node, index), scope);
821
+ const { vm } = scope;
822
+
823
+ switch (name) {
824
+ case "reset": {
825
+ restoreVm(vm, scope.initial);
826
+ scope.progress.ticks = 0;
827
+ scope.broadcasts.clear();
828
+ scope.capture.errors.length = 0;
829
+ return;
830
+ }
831
+ case "start":
832
+ vm.greenFlag();
833
+ return;
834
+ case "step":
835
+ stepTicks(scope, node.args.length ? Math.round(Number(arg(0))) : 1);
836
+ return;
837
+ case "broadcast": {
838
+ const message = String(arg(0));
839
+ scope.broadcasts.add(message);
840
+ vm.runtime.startHats("event_whenbroadcastreceived", {
841
+ BROADCAST_OPTION: message,
842
+ });
843
+ return;
844
+ }
845
+ case "broadcastAndWait": {
846
+ const message = String(arg(0));
847
+ scope.broadcasts.add(message);
848
+ const threads = vm.runtime.startHats("event_whenbroadcastreceived", {
849
+ BROADCAST_OPTION: message,
850
+ });
851
+ // Only wait for the scripts this broadcast started. Other threads, such
852
+ // as a `forever` loop launched by `start()`, keep running independently.
853
+ while (
854
+ Array.isArray(threads) &&
855
+ threads.some((thread) => vm.runtime.threads.includes(thread))
856
+ )
857
+ stepTicks(scope, 1);
858
+ return;
859
+ }
860
+ case "click": {
861
+ const target = node.args.length
862
+ ? vm.runtime.getSpriteTargetByName(String(arg(0)))
863
+ : null;
864
+ if (node.args.length && !target)
865
+ throw new TestError(
866
+ `No sprite named ${JSON.stringify(String(arg(0)))} to click`,
867
+ { diagnostics: [locOf(node)] },
868
+ );
869
+ if (!target) {
870
+ vm.runtime.startHats("event_whenstageclicked");
871
+ for (const sprite of vm.runtime.targets.filter((item) => !item.isStage))
872
+ vm.runtime.startHats("event_whenthisspriteclicked", null, sprite);
873
+ } else if (target.isStage) {
874
+ vm.runtime.startHats("event_whenstageclicked", null, target);
875
+ } else {
876
+ vm.runtime.startHats("event_whenthisspriteclicked", null, target);
877
+ }
878
+ return;
879
+ }
880
+ case "keyDown":
881
+ case "keyUp":
882
+ vm.postIOData("keyboard", {
883
+ key: String(arg(0)),
884
+ isDown: name === "keyDown",
885
+ });
886
+ return;
887
+ default:
888
+ // Anything that is not a built-in test action may be a project block.
889
+ callProjectProcedure(node, scope);
890
+ return;
891
+ }
892
+ }
893
+
894
+ // ---------------------------------------------------------------- run one test
895
+
896
+ /** Trim a failure result to the fields a reporter needs. */
897
+ function toError(result) {
898
+ return {
899
+ name: result.name,
900
+ message: result.message,
901
+ file: result.file,
902
+ line: result.line,
903
+ column: result.column,
904
+ expected: result.expected,
905
+ received: result.received,
906
+ };
907
+ }
908
+
909
+ /** Network APIs tests may not use without an explicit opt-in. */
910
+ const EXTERNAL_IO_APIS = ["fetch", "WebSocket"];
911
+
912
+ /**
913
+ * Make network access fail clearly instead of reaching the host.
914
+ *
915
+ * The VM loads its own extension sources through `fetch`, so URLs the runner
916
+ * already resolved for this project remain readable; every other HTTP request
917
+ * is rejected. Extensions the user explicitly trusts still run with full Node
918
+ * access, so this covers the VM's web APIs rather than containing code the
919
+ * user chose to run. Every replaced property is restored after the test.
920
+ */
921
+ export function installExternalIOGuard(extensionUrls = []) {
922
+ const allowed = new Set(extensionUrls);
923
+ for (const url of extensionUrls) {
924
+ try {
925
+ allowed.add(new URL(url).href);
926
+ } catch {
927
+ // Keep entries that cannot be parsed usable in their original form.
928
+ }
929
+ }
930
+ const saved = [];
931
+ const originalFetch = globalThis.fetch;
932
+ const block = (name) => {
933
+ throw new TestError(
934
+ `External network access is blocked during fx test (${name}). ` +
935
+ 'Pass "--allow-external-io" to allow it.',
936
+ { exitCode: TEST_EXIT.ERROR },
937
+ );
938
+ };
939
+
940
+ if (typeof originalFetch === "function") {
941
+ saved.push(["fetch", Object.getOwnPropertyDescriptor(globalThis, "fetch")]);
942
+ Object.defineProperty(globalThis, "fetch", {
943
+ configurable: true,
944
+ writable: true,
945
+ value: async function guardedFetch(input, init) {
946
+ const url =
947
+ typeof input === "string"
948
+ ? input
949
+ : input instanceof URL
950
+ ? input.href
951
+ : input?.url;
952
+ const text = String(url ?? "");
953
+ if (allowed.has(text) || /^(data|blob|file):/i.test(text))
954
+ return originalFetch.call(globalThis, input, init);
955
+ return block("fetch");
956
+ },
957
+ });
958
+ }
959
+
960
+ for (const name of EXTERNAL_IO_APIS) {
961
+ if (name === "fetch") continue;
962
+ const descriptor = Object.getOwnPropertyDescriptor(globalThis, name);
963
+ if (!descriptor) continue;
964
+ saved.push([name, descriptor]);
965
+ Object.defineProperty(globalThis, name, {
966
+ configurable: true,
967
+ writable: true,
968
+ value: function blockedExternalIO() {
969
+ block(name);
970
+ },
971
+ });
972
+ }
973
+ return () => {
974
+ for (const [name, descriptor] of saved)
975
+ if (descriptor) Object.defineProperty(globalThis, name, descriptor);
976
+ };
977
+ }
978
+
979
+ /** Run one test with external I/O blocked unless the user allowed it. */
980
+ async function runOneTest(options) {
981
+ const restoreExternalIO = options.config.allowExternalIO
982
+ ? () => {}
983
+ : installExternalIOGuard(Object.values(options.extensionUrls ?? {}));
984
+ try {
985
+ return await runOneTestInVm(options);
986
+ } finally {
987
+ restoreExternalIO();
988
+ }
989
+ }
990
+
991
+ /**
992
+ * Run one test in a fresh VM and return its outcome.
993
+ * @returns {Promise<object>} status, duration, ticks and any failure
994
+ */
995
+ async function runOneTestInVm({ test, helpers, sb3, config, seed }) {
996
+ const started = performance.now();
997
+ let vm;
998
+ try {
999
+ vm = await loadProject(sb3, {
1000
+ allowProjectExtensions: config.allowProjectExtensions,
1001
+ });
1002
+ } catch (error) {
1003
+ // A VM or extension load failure is an execution error, not a failed
1004
+ // expectation, so it must not be counted as a test failure.
1005
+ return {
1006
+ name: test.name,
1007
+ status: "error",
1008
+ durationMs: Math.round(performance.now() - started),
1009
+ ticks: 0,
1010
+ seed,
1011
+ error: {
1012
+ name: error?.name ?? "ProjectLoadError",
1013
+ message: `could not load the project: ${error?.message ?? String(error)}`,
1014
+ ...locOf(test),
1015
+ },
1016
+ };
1017
+ }
1018
+ vm.runtime.currentStepTime = 1000 / config.ticksPerSecond;
1019
+ // The sequencer refreshes this clock each frame; keep it on simulated time.
1020
+ vm.runtime.updateCurrentMSecs = () => {};
1021
+
1022
+ const capture = { errors: [], warnings: [] };
1023
+ const originalError = console.error;
1024
+ const originalWarn = console.warn;
1025
+ const originalRandom = Math.random;
1026
+ const runtime = vm.runtime;
1027
+ const compileErrorEvent =
1028
+ runtime.constructor?.COMPILE_ERROR ?? "COMPILE_ERROR";
1029
+ const onCompileError = (_target, error) =>
1030
+ capture.errors.push(error?.message ?? String(error));
1031
+ runtime.on?.(compileErrorEvent, onCompileError);
1032
+
1033
+ console.error = (...args) => capture.errors.push(args.map(String).join(" "));
1034
+ console.warn = (...args) => capture.warnings.push(args.map(String).join(" "));
1035
+ Math.random = mulberry32(seed);
1036
+
1037
+ const scope = createScope({
1038
+ vm,
1039
+ config,
1040
+ seed,
1041
+ capture,
1042
+ deadline: started + config.timeout,
1043
+ warnings: capture.warnings,
1044
+ });
1045
+ scope.initial = snapshotVm(vm);
1046
+
1047
+ let status = "passed";
1048
+ let failure = null;
1049
+ try {
1050
+ executeStatements(helpers, scope);
1051
+ executeStatements(test.body, scope);
1052
+ if (config.failOnRuntimeError && capture.errors.length > 0)
1053
+ throw new AssertionFailure({
1054
+ name: "RuntimeError",
1055
+ message: `runtime error: ${capture.errors[0]}`,
1056
+ ...locOf(test),
1057
+ });
1058
+ } catch (error) {
1059
+ status = "failed";
1060
+ if (error instanceof AssertionFailure) failure = toError(error.details);
1061
+ else if (error instanceof TimeoutFailure)
1062
+ failure = {
1063
+ name: "TimeoutError",
1064
+ message: error.message,
1065
+ ...locOf(test),
1066
+ };
1067
+ else if (error instanceof TestError)
1068
+ failure = {
1069
+ name: "TestLanguageError",
1070
+ message: error.message,
1071
+ ...(error.diagnostics[0] ?? locOf(test)),
1072
+ };
1073
+ else
1074
+ failure = {
1075
+ name: error?.name ?? "Error",
1076
+ message: error?.message ?? String(error),
1077
+ ...locOf(test),
1078
+ };
1079
+ } finally {
1080
+ runtime.off?.(compileErrorEvent, onCompileError);
1081
+ console.error = originalError;
1082
+ console.warn = originalWarn;
1083
+ Math.random = originalRandom;
1084
+ try {
1085
+ vm.stopAll();
1086
+ } catch {
1087
+ // A VM that failed to load has nothing to stop.
1088
+ }
1089
+ }
1090
+
1091
+ return {
1092
+ name: test.name,
1093
+ status,
1094
+ durationMs: Math.round(performance.now() - started),
1095
+ ticks: scope.progress.ticks,
1096
+ seed,
1097
+ error: failure,
1098
+ };
1099
+ }
1100
+
1101
+ // ---------------------------------------------------------------------- driver
1102
+
1103
+ /** Load vendored, feature and legacy extensions into one catalog. */
1104
+ async function loadCatalog({ configDir, loaded }) {
1105
+ const catalog = {};
1106
+ const extensionUrls = {};
1107
+ const warnings = [];
1108
+ if (loaded.vendor?.length) {
1109
+ const vendored = await loadVendorExtensions({ configDir, config: loaded });
1110
+ Object.assign(catalog, vendored.catalog);
1111
+ Object.assign(extensionUrls, vendored.extensionUrls);
1112
+ warnings.push(...vendored.warnings);
1113
+ }
1114
+ if (loaded.features?.length) {
1115
+ const features = await loadFeatureExtensions({ configDir, config: loaded });
1116
+ Object.assign(catalog, features.catalog);
1117
+ Object.assign(extensionUrls, features.extensionUrls);
1118
+ warnings.push(...features.warnings);
1119
+ }
1120
+ if (loaded.project?.extensions?.length) {
1121
+ const legacy = await loadExtensions(loaded.project.extensions, {
1122
+ baseDir: configDir,
1123
+ expectUnsandboxed: loaded.project.unsandboxed,
1124
+ reportUnmatchedUnsandboxed: false,
1125
+ });
1126
+ Object.assign(catalog, legacy.catalog);
1127
+ Object.assign(extensionUrls, legacy.extensionUrls);
1128
+ warnings.push(...legacy.warnings);
1129
+ }
1130
+ return { catalog, extensionUrls, warnings };
1131
+ }
1132
+
1133
+ /** One-line description of a failure, with its source location. */
1134
+ function describeFailure(error) {
1135
+ if (!error) return "";
1136
+ const where =
1137
+ error.file && error.line
1138
+ ? `${error.file}:${error.line}:${error.column ?? 0}`
1139
+ : (error.file ?? "");
1140
+ const head = `${error.name}: ${error.message}`;
1141
+ return where ? `${head}\n ${where}` : head;
1142
+ }
1143
+
1144
+ /**
1145
+ * Run project tests.
1146
+ *
1147
+ * @param {object} [options]
1148
+ * @param {string} [options.dir] project directory
1149
+ * @param {string[]} [options.files] explicit test files, relative to `cwd`
1150
+ * @param {string} [options.cwd] base for explicit files
1151
+ * @param {string} [options.filter] substring matched against file and test names
1152
+ * @param {object} [options.loaded] preloaded project config
1153
+ * @param {Record<string, object>} [options.catalog]
1154
+ * @param {Record<string, string>} [options.extensionUrls]
1155
+ * @param {number} [options.seed]
1156
+ * @param {number} [options.timeout]
1157
+ * @param {number} [options.ticksPerSecond]
1158
+ * @param {boolean} [options.failOnRuntimeError]
1159
+ * @param {boolean} [options.allowProjectExtensions] trust this project's extensions
1160
+ * @param {boolean} [options.allowExternalIO] allow network access during tests
1161
+ * @param {(event: object) => void} [options.onEvent]
1162
+ * @returns {Promise<object>}
1163
+ */
1164
+ export async function runTests({
1165
+ dir = process.cwd(),
1166
+ files,
1167
+ cwd = process.cwd(),
1168
+ filter = "",
1169
+ loaded: provided,
1170
+ catalog,
1171
+ extensionUrls,
1172
+ seed,
1173
+ timeout,
1174
+ ticksPerSecond,
1175
+ failOnRuntimeError,
1176
+ allowProjectExtensions = false,
1177
+ allowExternalIO = false,
1178
+ onEvent = () => {},
1179
+ } = {}) {
1180
+ const started = performance.now();
1181
+ const startDate = new Date();
1182
+ const root = path.resolve(dir);
1183
+ const loaded = provided ?? loadConfigFrom(root);
1184
+ const configDir = loaded.path ? path.dirname(loaded.path) : root;
1185
+ const settings = loaded.fx?.tests;
1186
+
1187
+ if (!settings)
1188
+ throw new TestError("Testing is disabled in this project.", {
1189
+ exitCode: TEST_EXIT.ERROR,
1190
+ hint: 'run "fx feature tests enable" to turn testing on',
1191
+ });
1192
+
1193
+ const config = {
1194
+ ...DEFAULT_TESTS,
1195
+ ...settings,
1196
+ seed: seed ?? settings.seed ?? DEFAULT_TESTS.seed,
1197
+ timeout: timeout ?? settings.timeout ?? DEFAULT_TESTS.timeout,
1198
+ ticksPerSecond:
1199
+ ticksPerSecond ?? settings.ticksPerSecond ?? DEFAULT_TESTS.ticksPerSecond,
1200
+ failOnRuntimeError:
1201
+ failOnRuntimeError ??
1202
+ settings.failOnRuntimeError ??
1203
+ DEFAULT_TESTS.failOnRuntimeError,
1204
+ // Trust and network access belong to this invocation, never to the project
1205
+ // configuration, so a cloned project cannot grant them to itself.
1206
+ allowProjectExtensions: Boolean(allowProjectExtensions),
1207
+ allowExternalIO: Boolean(allowExternalIO),
1208
+ };
1209
+
1210
+ const discovered = files?.length
1211
+ ? files.map((file) => {
1212
+ const absolute = path.resolve(cwd, file);
1213
+ return {
1214
+ file: absolute,
1215
+ relative: path.relative(root, absolute).split(path.sep).join("/"),
1216
+ };
1217
+ })
1218
+ : discoverTests({ dir: root, pattern: settings.pattern });
1219
+
1220
+ const result = {
1221
+ dir: root,
1222
+ seed: config.seed,
1223
+ version: productVersion(),
1224
+ startTime: startDate.toISOString(),
1225
+ durationMs: 0,
1226
+ files: [],
1227
+ diagnostics: [],
1228
+ counts: {
1229
+ files: discovered.length,
1230
+ passedFiles: 0,
1231
+ failedFiles: 0,
1232
+ errorFiles: 0,
1233
+ tests: 0,
1234
+ passed: 0,
1235
+ failed: 0,
1236
+ errors: 0,
1237
+ pending: 0,
1238
+ },
1239
+ ok: false,
1240
+ exitCode: TEST_EXIT.OK,
1241
+ };
1242
+
1243
+ onEvent({
1244
+ event: "run-start",
1245
+ dir: root,
1246
+ seed: config.seed,
1247
+ version: result.version,
1248
+ });
1249
+
1250
+ if (discovered.length === 0) {
1251
+ result.diagnostics.push({
1252
+ file: null,
1253
+ message: `No test files matched ${JSON.stringify(settings.pattern)} under ${root}`,
1254
+ });
1255
+ result.exitCode = TEST_EXIT.ERROR;
1256
+ onEvent({ event: "diagnostic", ...result.diagnostics[0] });
1257
+ result.durationMs = Math.round(performance.now() - started);
1258
+ onEvent({ event: "run-end", result });
1259
+ return result;
1260
+ }
1261
+
1262
+ const parsed = parseTests(
1263
+ discovered.map((entry) => ({
1264
+ file: entry.relative,
1265
+ source: fs.readFileSync(entry.file, "utf8"),
1266
+ })),
1267
+ );
1268
+ if (parsed.diagnostics.length > 0) {
1269
+ result.diagnostics.push(...parsed.diagnostics);
1270
+ for (const diagnostic of parsed.diagnostics)
1271
+ onEvent({ event: "diagnostic", ...diagnostic });
1272
+ result.exitCode = TEST_EXIT.ERROR;
1273
+ result.durationMs = Math.round(performance.now() - started);
1274
+ onEvent({ event: "run-end", result });
1275
+ return result;
1276
+ }
1277
+
1278
+ // Discovery happens before the build so reporters can show every selected
1279
+ // test as pending, then running, then completed.
1280
+ const needle = String(filter ?? "").toLowerCase();
1281
+ const wanted = (file, test) =>
1282
+ needle === "" ||
1283
+ file.toLowerCase().includes(needle) ||
1284
+ test.toLowerCase().includes(needle);
1285
+ onEvent({
1286
+ event: "discovered",
1287
+ files: parsed.files
1288
+ .map((file) => ({
1289
+ file: file.file,
1290
+ tests: file.tests
1291
+ .filter((test) => wanted(file.file, test.name))
1292
+ .map((test) => ({ name: test.name, status: "pending" })),
1293
+ }))
1294
+ .filter((file) => file.tests.length > 0),
1295
+ });
1296
+
1297
+ let extensions = { catalog, extensionUrls, warnings: [] };
1298
+ if (!catalog) extensions = await loadCatalog({ configDir, loaded });
1299
+
1300
+ // A project extension is arbitrary JavaScript that would run with Node
1301
+ // process access in the VM, including its top-level code and configured
1302
+ // extensions no test calls. Without an explicit user opt-in, stop before
1303
+ // running anything and say why instead of loading it or silently ignoring
1304
+ // it. The opt-in is an invocation option, not an fx.yml setting.
1305
+ const projectExtensionUrls = extensions.extensionUrls ?? {};
1306
+ if (
1307
+ !config.allowProjectExtensions &&
1308
+ Object.keys(projectExtensionUrls).length > 0
1309
+ ) {
1310
+ const ids = Object.keys(projectExtensionUrls).sort();
1311
+ result.diagnostics.push({
1312
+ file: null,
1313
+ message:
1314
+ `Project extensions are blocked during fx test: ${ids
1315
+ .map((id) => JSON.stringify(id))
1316
+ .join(", ")}. ` +
1317
+ 'Pass "--allow-project-extensions" to trust and load this project\'s extensions for this run.',
1318
+ });
1319
+ result.exitCode = TEST_EXIT.ERROR;
1320
+ onEvent({ event: "diagnostic", ...result.diagnostics[0] });
1321
+ result.durationMs = Math.round(performance.now() - started);
1322
+ onEvent({ event: "run-end", result });
1323
+ return result;
1324
+ }
1325
+
1326
+ let built;
1327
+ try {
1328
+ built = build({
1329
+ dir: root,
1330
+ projectConfig: loaded.project,
1331
+ fxConfig: loaded.fx,
1332
+ catalog: extensions.catalog,
1333
+ extensionUrls: extensions.extensionUrls,
1334
+ useConfigFile: false,
1335
+ testPattern: settings.pattern,
1336
+ });
1337
+ } catch (error) {
1338
+ result.diagnostics.push({
1339
+ file: error.file ?? null,
1340
+ line: error.line ?? null,
1341
+ column: error.column ?? null,
1342
+ message: error.message,
1343
+ });
1344
+ onEvent({ event: "diagnostic", ...result.diagnostics[0] });
1345
+ result.exitCode = TEST_EXIT.ERROR;
1346
+ result.durationMs = Math.round(performance.now() - started);
1347
+ onEvent({ event: "run-end", result });
1348
+ return result;
1349
+ }
1350
+
1351
+ for (const file of parsed.files) {
1352
+ const selected = file.tests.filter((test) => wanted(file.file, test.name));
1353
+ if (selected.length === 0) continue;
1354
+
1355
+ const fileResult = {
1356
+ file: file.file,
1357
+ status: "passed",
1358
+ durationMs: 0,
1359
+ tests: [],
1360
+ };
1361
+ const fileStart = performance.now();
1362
+ onEvent({ event: "file-start", file: file.file });
1363
+
1364
+ for (const test of selected) {
1365
+ onEvent({
1366
+ event: "test-start",
1367
+ file: file.file,
1368
+ name: test.name,
1369
+ status: "running",
1370
+ });
1371
+ let outcome;
1372
+ try {
1373
+ outcome = await runOneTest({
1374
+ test,
1375
+ helpers: file.helpers,
1376
+ sb3: built.sb3,
1377
+ config,
1378
+ seed: config.seed,
1379
+ extensionUrls: extensions.extensionUrls,
1380
+ });
1381
+ } catch (error) {
1382
+ outcome = {
1383
+ name: test.name,
1384
+ status: "error",
1385
+ durationMs: 0,
1386
+ ticks: 0,
1387
+ seed: config.seed,
1388
+ error: {
1389
+ name: error?.name ?? "Error",
1390
+ message: error?.message ?? String(error),
1391
+ ...locOf(test),
1392
+ },
1393
+ };
1394
+ }
1395
+ fileResult.tests.push(outcome);
1396
+ result.counts.tests += 1;
1397
+ if (outcome.status === "passed") result.counts.passed += 1;
1398
+ else if (outcome.status === "failed") result.counts.failed += 1;
1399
+ else if (outcome.status === "error") {
1400
+ result.counts.errors += 1;
1401
+ const diagnostic = {
1402
+ file: file.file,
1403
+ message: `${test.name}: ${outcome.error?.message ?? "test execution failed"}`,
1404
+ };
1405
+ result.diagnostics.push(diagnostic);
1406
+ onEvent({ event: "diagnostic", ...diagnostic });
1407
+ } else result.counts.pending += 1;
1408
+ onEvent({
1409
+ event: "test-result",
1410
+ file: file.file,
1411
+ ...outcome,
1412
+ });
1413
+ }
1414
+
1415
+ fileResult.durationMs = Math.round(performance.now() - fileStart);
1416
+ if (fileResult.tests.some((item) => item.status === "error"))
1417
+ fileResult.status = "error";
1418
+ else if (fileResult.tests.some((item) => item.status === "failed"))
1419
+ fileResult.status = "failed";
1420
+ result.files.push(fileResult);
1421
+ if (fileResult.status === "passed") result.counts.passedFiles += 1;
1422
+ else if (fileResult.status === "failed") result.counts.failedFiles += 1;
1423
+ else result.counts.errorFiles += 1;
1424
+ onEvent({
1425
+ event: "file-result",
1426
+ file: file.file,
1427
+ status: fileResult.status,
1428
+ tests: fileResult.tests,
1429
+ durationMs: fileResult.durationMs,
1430
+ });
1431
+ }
1432
+
1433
+ result.durationMs = Math.round(performance.now() - started);
1434
+ result.ok =
1435
+ result.counts.failed === 0 &&
1436
+ result.counts.errors === 0 &&
1437
+ result.counts.tests > 0;
1438
+ result.exitCode =
1439
+ result.counts.tests === 0 || result.counts.errors > 0
1440
+ ? TEST_EXIT.ERROR
1441
+ : result.counts.failed > 0
1442
+ ? TEST_EXIT.FAILED
1443
+ : TEST_EXIT.OK;
1444
+ if (result.counts.tests === 0)
1445
+ result.diagnostics.push({
1446
+ file: null,
1447
+ message: `No tests matched ${JSON.stringify(filter)}`,
1448
+ });
1449
+
1450
+ onEvent({ event: "run-end", result });
1451
+ return result;
1452
+ }
1453
+
1454
+ /** Format one failure for reporters. */
1455
+ export function formatFailure(error) {
1456
+ return describeFailure(error);
1457
+ }