content-function.js 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. import * as path from 'node:path';
  2. import {fileURLToPath} from 'node:url';
  3. import {inspect} from 'node:util';
  4. import chroma from 'chroma-js';
  5. import {showAggregate} from '#aggregate';
  6. import {getColors} from '#colors';
  7. import {quickLoadContentDependencies} from '#content-dependencies';
  8. import {quickEvaluate} from '#content-function';
  9. import * as html from '#html';
  10. import {internalDefaultStringsFile, processLanguageFile} from '#language';
  11. import {empty} from '#sugar';
  12. import {generateURLs, thumb, urlSpec} from '#urls';
  13. import mock from './generic-mock.js';
  14. const __dirname = path.dirname(fileURLToPath(import.meta.url));
  15. function cleanURLSpec(reference) {
  16. const prepared = structuredClone(reference);
  17. for (const spec of Object.values(prepared)) {
  18. if (spec.prefix) {
  19. // Strip out STATIC_VERSION. This updates fairly regularly and we
  20. // don't want it to affect snapshot tests.
  21. spec.prefix = spec.prefix
  22. .replace(/static-\d+[a-z]\d+/i, 'static');
  23. }
  24. }
  25. return prepared;
  26. }
  27. export function testContentFunctions(t, message, fn) {
  28. const urls = generateURLs(cleanURLSpec(urlSpec));
  29. t.test(message, async t => {
  30. let loadedContentDependencies;
  31. const language = await processLanguageFile(internalDefaultStringsFile);
  32. const mocks = [];
  33. const evaluate = ({
  34. from = 'localized.home',
  35. contentDependencies = {},
  36. extraDependencies = {},
  37. ...opts
  38. }) => {
  39. if (!loadedContentDependencies) {
  40. throw new Error(`Await .load() before performing tests`);
  41. }
  42. const {to} = urls.from(from);
  43. return cleanCatchAggregate(() => {
  44. return quickEvaluate({
  45. ...opts,
  46. contentDependencies: {
  47. ...contentDependencies,
  48. ...loadedContentDependencies,
  49. },
  50. extraDependencies: {
  51. html,
  52. language,
  53. thumb,
  54. to,
  55. urls,
  56. pagePath: ['home'],
  57. appendIndexHTML: false,
  58. getColors: c => getColors(c, {chroma}),
  59. wikiData: {
  60. wikiInfo: {},
  61. },
  62. ...extraDependencies,
  63. },
  64. });
  65. });
  66. };
  67. evaluate.load = async (opts) => {
  68. if (loadedContentDependencies) {
  69. throw new Error(`Already loaded!`);
  70. }
  71. loadedContentDependencies = await asyncCleanCatchAggregate(() =>
  72. quickLoadContentDependencies({
  73. logging: false,
  74. ...opts,
  75. }));
  76. };
  77. evaluate.snapshot = (...args) => {
  78. if (!loadedContentDependencies) {
  79. throw new Error(`Await .load() before performing tests`);
  80. }
  81. const [description, opts] =
  82. (typeof args[0] === 'string'
  83. ? args
  84. : ['output', ...args]);
  85. let result = evaluate(opts);
  86. if (opts.multiple) {
  87. result = result.map(item => item.toString()).join('\n');
  88. } else {
  89. result = result.toString();
  90. }
  91. t.matchSnapshot(result, description);
  92. };
  93. evaluate.stubTemplate = name =>
  94. // Creates a particularly permissable template, allowing any slot values
  95. // to be stored and just outputting the contents of those slots as-are.
  96. _stubTemplate(name, false);
  97. evaluate.stubContentFunction = name =>
  98. // Like stubTemplate, but instead of a template directly, returns
  99. // an object describing a content function - suitable for passing
  100. // into evaluate.mock.
  101. _stubTemplate(name, true);
  102. const _stubTemplate = (name, mockContentFunction) => {
  103. const inspectNicely = (value, opts = {}) =>
  104. inspect(value, {
  105. ...opts,
  106. colors: false,
  107. sort: true,
  108. });
  109. const makeTemplate = formatContentFn =>
  110. new (class extends html.Template {
  111. #slotValues = {};
  112. constructor() {
  113. super({
  114. content: () => this.#getContent(formatContentFn),
  115. });
  116. }
  117. setSlots(slotNamesToValues) {
  118. Object.assign(this.#slotValues, slotNamesToValues);
  119. }
  120. setSlot(slotName, slotValue) {
  121. this.#slotValues[slotName] = slotValue;
  122. }
  123. #getContent(formatContentFn) {
  124. const toInspect =
  125. Object.fromEntries(
  126. Object.entries(this.#slotValues)
  127. .filter(([key, value]) => value !== null));
  128. const inspected =
  129. inspectNicely(toInspect, {
  130. breakLength: Infinity,
  131. compact: true,
  132. depth: Infinity,
  133. });
  134. return formatContentFn(inspected); `${name}: ${inspected}`;
  135. }
  136. });
  137. if (mockContentFunction) {
  138. return {
  139. data: (...args) => ({args}),
  140. generate: (data) =>
  141. makeTemplate(slots => {
  142. const argsLines =
  143. (empty(data.args)
  144. ? []
  145. : inspectNicely(data.args, {depth: Infinity})
  146. .split('\n'));
  147. return (`[mocked: ${name}` +
  148. (empty(data.args)
  149. ? ``
  150. : argsLines.length === 1
  151. ? `\n args: ${argsLines[0]}`
  152. : `\n args: ${argsLines[0]}\n` +
  153. argsLines.slice(1).join('\n').replace(/^/gm, ' ')) +
  154. (!empty(data.args)
  155. ? `\n `
  156. : ` - `) +
  157. (slots
  158. ? `slots: ${slots}]`
  159. : `slots: none]`));
  160. }),
  161. };
  162. } else {
  163. return makeTemplate(slots => `${name}: ${slots}`);
  164. }
  165. };
  166. evaluate.mock = (...opts) => {
  167. const {value, close} = mock(...opts);
  168. mocks.push({close});
  169. return value;
  170. };
  171. evaluate.mock.transformContent = {
  172. transformContent: {
  173. extraDependencies: ['html'],
  174. data: content => ({content}),
  175. slots: {mode: {type: 'string'}},
  176. generate: ({content}) => content,
  177. },
  178. };
  179. await fn(t, evaluate);
  180. if (!empty(mocks)) {
  181. cleanCatchAggregate(() => {
  182. const errors = [];
  183. for (const {close} of mocks) {
  184. try {
  185. close();
  186. } catch (error) {
  187. errors.push(error);
  188. }
  189. }
  190. if (!empty(errors)) {
  191. throw new AggregateError(errors, `Errors closing mocks`);
  192. }
  193. });
  194. }
  195. });
  196. }
  197. function printAggregate(error) {
  198. if (error instanceof AggregateError) {
  199. const message = showAggregate(error, {
  200. showTraces: true,
  201. print: false,
  202. pathToFileURL: f => path.relative(path.join(__dirname, '../..'), fileURLToPath(f)),
  203. });
  204. for (const line of message.split('\n')) {
  205. console.error(line);
  206. }
  207. }
  208. }
  209. function cleanCatchAggregate(fn) {
  210. try {
  211. return fn();
  212. } catch (error) {
  213. printAggregate(error);
  214. throw error;
  215. }
  216. }
  217. async function asyncCleanCatchAggregate(fn) {
  218. try {
  219. return await fn();
  220. } catch (error) {
  221. printAggregate(error);
  222. throw error;
  223. }
  224. }