apostrophe 4.30.0 → 4.31.0-alpha.1

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 (125) hide show
  1. package/CHANGELOG.md +67 -2
  2. package/claude-tools/detect-handles.js +46 -0
  3. package/claude-tools/minimal-hang-test.js +28 -0
  4. package/claude-tools/mongo-close-test.js +11 -0
  5. package/claude-tools/stdin-ref-test.js +14 -0
  6. package/eslint.config.js +3 -1
  7. package/modules/@apostrophecms/area/index.js +94 -2
  8. package/modules/@apostrophecms/area/lib/custom-tags/area.js +1 -40
  9. package/modules/@apostrophecms/area/ui/apos/components/AposBreadcrumbOperations.vue +0 -1
  10. package/modules/@apostrophecms/area/ui/apos/components/AposWidgetControls.vue +0 -1
  11. package/modules/@apostrophecms/attachment/index.js +4 -1
  12. package/modules/@apostrophecms/db/index.js +68 -27
  13. package/modules/@apostrophecms/doc-type/ui/apos/logic/AposDocContextMenu.js +5 -3
  14. package/modules/@apostrophecms/express/index.js +2 -0
  15. package/modules/@apostrophecms/http/index.js +1 -1
  16. package/modules/@apostrophecms/i18n/i18n/en.json +3 -0
  17. package/modules/@apostrophecms/image/ui/apos/components/AposMediaManagerEditor.vue +2 -2
  18. package/modules/@apostrophecms/job/index.js +9 -7
  19. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridColumn.vue +0 -1
  20. package/modules/@apostrophecms/layout-widget/ui/apos/components/AposGridManager.vue +0 -1
  21. package/modules/@apostrophecms/login/ui/apos/components/TheAposLogin.vue +10 -2
  22. package/modules/@apostrophecms/login/ui/apos/components/TheAposLoginHeader.vue +3 -3
  23. package/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue +52 -23
  24. package/modules/@apostrophecms/modal/ui/apos/components/AposModalTabs.vue +6 -1
  25. package/modules/@apostrophecms/oembed/index.js +2 -1
  26. package/modules/@apostrophecms/piece-page-type/index.js +7 -0
  27. package/modules/@apostrophecms/piece-type/index.js +2 -1
  28. package/modules/@apostrophecms/piece-type/ui/apos/components/AposDocsManagerDisplay.vue +7 -2
  29. package/modules/@apostrophecms/recently-edited/ui/apos/components/AposCellTitle.vue +1 -0
  30. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposRichTextWidgetEditor.vue +21 -4
  31. package/modules/@apostrophecms/schema/ui/apos/components/AposArrayEditor.vue +1 -0
  32. package/modules/@apostrophecms/schema/ui/apos/components/AposInputDateAndTime.vue +7 -2
  33. package/modules/@apostrophecms/schema/ui/apos/components/AposInputSelect.vue +1 -0
  34. package/modules/@apostrophecms/schema/ui/apos/components/AposInputWrapper.vue +1 -1
  35. package/modules/@apostrophecms/schema/ui/apos/components/AposSubform.vue +1 -0
  36. package/modules/@apostrophecms/schema/ui/apos/logic/AposSubform.js +10 -0
  37. package/modules/@apostrophecms/styles/ui/apos/components/TheAposStyles.vue +1 -0
  38. package/modules/@apostrophecms/template/index.js +117 -11
  39. package/modules/@apostrophecms/template/lib/jsxLoader.js +128 -0
  40. package/modules/@apostrophecms/template/lib/jsxRender.js +490 -0
  41. package/modules/@apostrophecms/template/lib/jsxRuntime.js +276 -0
  42. package/modules/@apostrophecms/template/lib/nunjucksLoader.js +11 -36
  43. package/modules/@apostrophecms/template/lib/viewWatcher.js +113 -0
  44. package/modules/@apostrophecms/ui/ui/apos/components/AposButtonGroup.vue +1 -1
  45. package/modules/@apostrophecms/ui/ui/apos/components/AposCellLastEdited.vue +1 -1
  46. package/modules/@apostrophecms/ui/ui/apos/components/AposSelect.vue +1 -0
  47. package/modules/@apostrophecms/ui/ui/apos/components/AposSlat.vue +10 -4
  48. package/modules/@apostrophecms/ui/ui/apos/components/AposSlatList.vue +6 -1
  49. package/modules/@apostrophecms/ui/ui/apos/components/AposSubformPreview.vue +1 -1
  50. package/modules/@apostrophecms/ui/ui/apos/components/AposTreeHeader.vue +1 -1
  51. package/modules/@apostrophecms/ui/ui/apos/scss/global/_theme.scss +1 -0
  52. package/modules/@apostrophecms/uploadfs/index.js +3 -0
  53. package/modules/@apostrophecms/util/index.js +3 -3
  54. package/package.json +14 -10
  55. package/test/add-missing-schema-fields-project/test.js +22 -3
  56. package/test/assets.js +110 -67
  57. package/test/db-tools.js +365 -0
  58. package/test/db.js +24 -15
  59. package/test/default-adapter.js +256 -0
  60. package/test/external-front.js +419 -1
  61. package/test/job.js +1 -1
  62. package/test/modules/jsx-area-test/index.js +23 -0
  63. package/test/modules/jsx-area-test/views/bad-area.jsx +7 -0
  64. package/test/modules/jsx-area-test/views/with-area-ctx.jsx +13 -0
  65. package/test/modules/jsx-area-test/views/with-area.jsx +7 -0
  66. package/test/modules/jsx-area-test/views/with-widget-ctx.jsx +12 -0
  67. package/test/modules/jsx-area-test/views/with-widget.jsx +7 -0
  68. package/test/modules/jsx-async-widget/index.js +6 -0
  69. package/test/modules/jsx-async-widget/views/widget.jsx +11 -0
  70. package/test/modules/jsx-bridge-test/index.js +1 -0
  71. package/test/modules/jsx-bridge-test/views/cross-module.jsx +7 -0
  72. package/test/modules/jsx-bridge-test/views/disambig-name-only.jsx +7 -0
  73. package/test/modules/jsx-bridge-test/views/disambig-target.jsx +8 -0
  74. package/test/modules/jsx-bridge-test/views/disambig-with-template-name.jsx +7 -0
  75. package/test/modules/jsx-bridge-test/views/include-html.jsx +7 -0
  76. package/test/modules/jsx-bridge-test/views/include-target.html +4 -0
  77. package/test/modules/jsx-bridge-test/views/jsx-extends-via-extend.jsx +9 -0
  78. package/test/modules/jsx-bridge-test/views/jsx-extends.jsx +9 -0
  79. package/test/modules/jsx-bridge-test/views/jsx-layout.jsx +14 -0
  80. package/test/modules/jsx-bridge-test/views/njk-extends.jsx +14 -0
  81. package/test/modules/jsx-bridge-test/views/njk-layout.html +9 -0
  82. package/test/modules/jsx-bridge-test/views/short-form.jsx +7 -0
  83. package/test/modules/jsx-bridge-test/views/short-target.jsx +3 -0
  84. package/test/modules/jsx-component-test/index.js +15 -0
  85. package/test/modules/jsx-component-test/views/greet.html +1 -0
  86. package/test/modules/jsx-component-test/views/uses-component.jsx +8 -0
  87. package/test/modules/jsx-ctx-widget/index.js +6 -0
  88. package/test/modules/jsx-ctx-widget/views/widget.jsx +4 -0
  89. package/test/modules/jsx-mixed-test/index.js +9 -0
  90. package/test/modules/jsx-mixed-test/views/apos-full.jsx +21 -0
  91. package/test/modules/jsx-mixed-test/views/async-list.jsx +12 -0
  92. package/test/modules/jsx-mixed-test/views/lib/format.js +3 -0
  93. package/test/modules/jsx-mixed-test/views/localized.jsx +3 -0
  94. package/test/modules/jsx-mixed-test/views/partial.jsx +3 -0
  95. package/test/modules/jsx-mixed-test/views/safe-helper.jsx +3 -0
  96. package/test/modules/jsx-mixed-test/views/syntax-error.jsx +3 -0
  97. package/test/modules/jsx-mixed-test/views/throws.jsx +5 -0
  98. package/test/modules/jsx-mixed-test/views/uses-import.jsx +5 -0
  99. package/test/modules/jsx-mixed-test/views/uses-require.jsx +5 -0
  100. package/test/modules/jsx-watcher-cross-test/index.js +5 -0
  101. package/test/modules/jsx-watcher-cross-test/views/cross-template.jsx +3 -0
  102. package/test/modules/jsx-watcher-test/index.js +5 -0
  103. package/test/modules/jsx-watcher-test/views/watcher-test.jsx +3 -0
  104. package/test/modules/template-jsx-options-test/index.js +12 -0
  105. package/test/modules/template-jsx-options-test/views/options-test.jsx +9 -0
  106. package/test/modules/template-jsx-subclass-test/index.js +3 -0
  107. package/test/modules/template-jsx-subclass-test/views/override-test.jsx +3 -0
  108. package/test/modules/template-jsx-test/index.js +9 -0
  109. package/test/modules/template-jsx-test/views/boolean-attrs.jsx +11 -0
  110. package/test/modules/template-jsx-test/views/class-and-for.jsx +7 -0
  111. package/test/modules/template-jsx-test/views/dangerously-set.jsx +3 -0
  112. package/test/modules/template-jsx-test/views/escape-attr.jsx +3 -0
  113. package/test/modules/template-jsx-test/views/escape-body.jsx +3 -0
  114. package/test/modules/template-jsx-test/views/inherit-test.jsx +3 -0
  115. package/test/modules/template-jsx-test/views/list.jsx +7 -0
  116. package/test/modules/template-jsx-test/views/override-test.jsx +3 -0
  117. package/test/modules/template-jsx-test/views/svg-attrs.jsx +27 -0
  118. package/test/modules/template-jsx-test/views/test.jsx +3 -0
  119. package/test/modules/template-jsx-test/views/void-elements.jsx +9 -0
  120. package/test/templates-jsx-watcher.js +135 -0
  121. package/test/templates-jsx.js +537 -0
  122. package/test-lib/util.js +50 -14
  123. package/.claude/settings.local.json +0 -15
  124. package/lib/mongodb-connect.js +0 -62
  125. package/test/add-missing-schema-fields-project/node_modules/.package-lock.json +0 -131
@@ -0,0 +1,537 @@
1
+ const t = require('../test-lib/test.js');
2
+ const assert = require('assert/strict');
3
+
4
+ describe('Templates: JSX', function() {
5
+
6
+ let apos;
7
+
8
+ this.timeout(t.timeout);
9
+
10
+ after(async function() {
11
+ return t.destroy(apos);
12
+ });
13
+
14
+ before(async function() {
15
+ apos = await t.create({
16
+ root: module,
17
+ modules: {
18
+ 'express-test': {},
19
+ 'template-jsx-test': {
20
+ options: {
21
+ ignoreNoCodeWarning: true
22
+ }
23
+ },
24
+ 'template-jsx-subclass-test': {
25
+ options: {
26
+ ignoreNoCodeWarning: true
27
+ }
28
+ },
29
+ 'template-jsx-options-test': {},
30
+ 'jsx-component-test': {},
31
+ 'jsx-bridge-test': {
32
+ options: {
33
+ ignoreNoCodeWarning: true
34
+ }
35
+ },
36
+ 'jsx-mixed-test': {},
37
+ 'jsx-area-test': {},
38
+ 'jsx-async-widget': {},
39
+ 'jsx-ctx-widget': {}
40
+ }
41
+ });
42
+ });
43
+
44
+ // ---- Equivalents of the existing Nunjucks template tests ----
45
+
46
+ it('should render a JSX template relative to a module', async function() {
47
+ const req = apos.task.getAnonReq();
48
+ const result = await apos.modules['template-jsx-test'].render(req, 'test', { age: 50 });
49
+ assert.equal(result.trim(), '<h1>50</h1>');
50
+ });
51
+
52
+ it('should respect templateData at module level for JSX templates', async function() {
53
+ const req = apos.task.getAnonReq();
54
+ const result = await apos.modules['template-jsx-test'].render(req, 'test');
55
+ assert.equal(result.trim(), '<h1>30</h1>');
56
+ });
57
+
58
+ it('should respect JSX template overrides in subclasses', async function() {
59
+ const req = apos.task.getAnonReq();
60
+ const result = await apos.modules['template-jsx-subclass-test'].render(req, 'override-test');
61
+ assert.equal(result.trim(), '<h1>I am overridden</h1>');
62
+ });
63
+
64
+ it('should inherit JSX templates in the absence of overrides', async function() {
65
+ const req = apos.task.getAnonReq();
66
+ const result = await apos.modules['template-jsx-subclass-test'].render(req, 'inherit-test');
67
+ assert.equal(result.trim(), '<h1>I am inherited</h1>');
68
+ });
69
+
70
+ it('should expose module options and helpers to JSX via the helpers second argument', async function() {
71
+ const req = apos.task.getAnonReq();
72
+ const result = await apos.modules['template-jsx-options-test'].render(req, 'options-test');
73
+ assert.match(result, /nifty/);
74
+ assert.match(result, /\b4\b/);
75
+ });
76
+
77
+ // ---- Auto-escape and dangerouslySetInnerHTML ----
78
+
79
+ it('should auto-escape strings in element bodies', async function() {
80
+ const req = apos.task.getAnonReq();
81
+ const result = await apos.modules['template-jsx-test'].render(req, 'escape-body', {
82
+ html: '<script>alert("xss")</script>'
83
+ });
84
+ assert.match(result, /&lt;script&gt;alert\("xss"\)&lt;\/script&gt;/);
85
+ assert.doesNotMatch(result, /<script>/i);
86
+ });
87
+
88
+ it('should auto-escape attribute values, including quotes', async function() {
89
+ const req = apos.task.getAnonReq();
90
+ const result = await apos.modules['template-jsx-test'].render(req, 'escape-attr', {
91
+ url: 'javascript:alert("hi")',
92
+ title: 'evil "quotes" & <tags>'
93
+ });
94
+ assert.match(result, /href="javascript:alert\(&quot;hi&quot;\)"/);
95
+ assert.match(result, /title="evil &quot;quotes&quot; &amp; &lt;tags&gt;"/);
96
+ });
97
+
98
+ it('should emit raw HTML via dangerouslySetInnerHTML without escaping', async function() {
99
+ const req = apos.task.getAnonReq();
100
+ const result = await apos.modules['template-jsx-test'].render(req, 'dangerously-set', {
101
+ html: '<strong>raw</strong> & <em>html</em>'
102
+ });
103
+ assert.match(result, /<strong>raw<\/strong> & <em>html<\/em>/);
104
+ });
105
+
106
+ it('should rewrite className to class and htmlFor to for', async function() {
107
+ const req = apos.task.getAnonReq();
108
+ const result = await apos.modules['template-jsx-test'].render(req, 'class-and-for');
109
+ assert.match(result, /class="form-label"/);
110
+ assert.match(result, /for="username"/);
111
+ assert.doesNotMatch(result, /className=/);
112
+ assert.doesNotMatch(result, /htmlFor=/);
113
+ });
114
+
115
+ it('should rewrite camelCase SVG presentation attrs to kebab-case', async function() {
116
+ const req = apos.task.getAnonReq();
117
+ const result = await apos.modules['template-jsx-test'].render(req, 'svg-attrs');
118
+ // Kebab-case conversions
119
+ assert.match(result, /stroke-width="2"/);
120
+ assert.match(result, /stroke-linecap="round"/);
121
+ assert.match(result, /stroke-linejoin="round"/);
122
+ assert.match(result, /stroke-dasharray="4 2"/);
123
+ assert.match(result, /stroke-opacity="0.5"/);
124
+ assert.match(result, /fill-rule="evenodd"/);
125
+ assert.match(result, /clip-rule="evenodd"/);
126
+ assert.match(result, /pointer-events="none"/);
127
+ assert.match(result, /text-anchor="middle"/);
128
+ // Native camelCase preserved
129
+ assert.match(result, /viewBox="0 0 24 24"/);
130
+ assert.match(result, /preserveAspectRatio="xMidYMid meet"/);
131
+ // No camelCase forms leaked through
132
+ assert.doesNotMatch(result, /strokeWidth=/);
133
+ assert.doesNotMatch(result, /fillRule=/);
134
+ });
135
+
136
+ it('should self-close void elements', async function() {
137
+ const req = apos.task.getAnonReq();
138
+ const result = await apos.modules['template-jsx-test'].render(req, 'void-elements');
139
+ assert.match(result, /<hr \/>/);
140
+ assert.match(result, /<input type="text" name="email" \/>/);
141
+ assert.match(result, /<br \/>/);
142
+ });
143
+
144
+ it('should handle boolean attributes and skip null/undefined/false attribute values', async function() {
145
+ const req = apos.task.getAnonReq();
146
+ const result = await apos.modules['template-jsx-test'].render(req, 'boolean-attrs');
147
+ assert.match(result, /<input/);
148
+ assert.match(result, / checked/);
149
+ assert.doesNotMatch(result, /checked="true"/);
150
+ assert.doesNotMatch(result, /disabled/);
151
+ assert.doesNotMatch(result, /data-extra/);
152
+ assert.doesNotMatch(result, /data-undefined/);
153
+ });
154
+
155
+ it('should render arrays of nodes (e.g. lists from .map())', async function() {
156
+ const req = apos.task.getAnonReq();
157
+ const result = await apos.modules['template-jsx-test'].render(req, 'list', {
158
+ items: [ 'a', 'b', 'c' ]
159
+ });
160
+ assert.match(result, /<ul>/);
161
+ assert.match(result, /<li>a<\/li>/);
162
+ assert.match(result, /<li>b<\/li>/);
163
+ assert.match(result, /<li>c<\/li>/);
164
+ });
165
+
166
+ // ---- Async <Component> ----
167
+
168
+ it('should await async components inline within JSX', async function() {
169
+ const req = apos.task.getAnonReq();
170
+ const result = await apos.modules['jsx-component-test'].render(req, 'uses-component', {
171
+ name: 'World'
172
+ });
173
+ assert.match(result, /<section>/);
174
+ assert.match(result, /<h1>Welcome<\/h1>/);
175
+ assert.match(
176
+ result,
177
+ /<span class="greet">Hello World \(after delay\)<\/span>/
178
+ );
179
+ });
180
+
181
+ // ---- <Area> ----
182
+
183
+ it('should render a doc area inline via the Area helper', async function() {
184
+ const req = apos.task.getAnonReq();
185
+ const piece = {
186
+ _id: 'jsx-area-piece-1',
187
+ metaType: 'doc',
188
+ type: 'jsx-area-test',
189
+ title: 'Area Host',
190
+ slug: 'area-host',
191
+ main: {
192
+ _id: 'jsx-area-1',
193
+ metaType: 'area',
194
+ items: [
195
+ {
196
+ _id: 'jsx-area-widget-1',
197
+ metaType: 'widget',
198
+ type: '@apostrophecms/rich-text',
199
+ content: '<p>area body</p>'
200
+ }
201
+ ]
202
+ }
203
+ };
204
+ const result = await apos.modules['jsx-area-test'].render(req, 'with-area', { piece });
205
+ assert.match(result, /<main>/);
206
+ assert.match(result, /class="apos-area"/);
207
+ assert.match(result, /<div data-rich-text>/);
208
+ assert.match(result, /<p>area body<\/p>/);
209
+ });
210
+
211
+ it('Area helper should reject calls with a missing or invalid name', async function() {
212
+ const req = apos.task.getAnonReq();
213
+ const piece = {
214
+ _id: 'jsx-area-piece-2',
215
+ metaType: 'doc',
216
+ type: 'jsx-area-test',
217
+ title: 'Bad Area',
218
+ slug: 'bad-area',
219
+ // No `nope` field exists in the schema
220
+ nope: {
221
+ _id: 'jsx-area-nope-1',
222
+ metaType: 'area',
223
+ items: []
224
+ }
225
+ };
226
+ let caught;
227
+ try {
228
+ await apos.modules['jsx-area-test'].render(req, 'bad-area', { piece });
229
+ } catch (e) {
230
+ caught = e;
231
+ }
232
+ assert(caught, 'expected the render to throw');
233
+ assert.match(caught.message, /no field named nope/);
234
+ });
235
+
236
+ // ---- <Widget> ----
237
+
238
+ it('should render a single widget via the Widget helper', async function() {
239
+ const req = apos.task.getAnonReq();
240
+ const widget = {
241
+ _id: 'jsx-widget-direct-1',
242
+ metaType: 'widget',
243
+ type: '@apostrophecms/rich-text',
244
+ content: '<p>just a widget</p>'
245
+ };
246
+ const result = await apos.modules['jsx-area-test'].render(req, 'with-widget', { widget });
247
+ assert.match(result, /<section class="wrapper">/);
248
+ assert.match(result, /<div data-rich-text>/);
249
+ assert.match(result, /<p>just a widget<\/p>/);
250
+ // Widget should NOT add the surrounding apos-area wrapper.
251
+ assert.doesNotMatch(result, /class="apos-area"/);
252
+ });
253
+
254
+ it('Widget helper should silently skip a null widget', async function() {
255
+ const req = apos.task.getAnonReq();
256
+ const result = await apos.modules['jsx-area-test'].render(req, 'with-widget', { widget: null });
257
+ assert.match(result, /<section class="wrapper"><\/section>/);
258
+ });
259
+
260
+ // ---- `with={...}` context options on Area and Widget ----
261
+
262
+ it('Area should pass `with={...}` context options through to the widget', async function() {
263
+ const req = apos.task.getAnonReq();
264
+ const piece = {
265
+ _id: 'jsx-ctx-area-piece-1',
266
+ metaType: 'doc',
267
+ type: 'jsx-area-test',
268
+ title: 'Ctx Host',
269
+ slug: 'ctx-host',
270
+ main: {
271
+ _id: 'jsx-ctx-area-1',
272
+ metaType: 'area',
273
+ items: [
274
+ {
275
+ _id: 'jsx-ctx-widget-1',
276
+ metaType: 'widget',
277
+ type: 'jsx-ctx'
278
+ }
279
+ ]
280
+ }
281
+ };
282
+ const result = await apos.modules['jsx-area-test'].render(req, 'with-area-ctx', { piece });
283
+ assert.match(result, /data-tag="from-area-with"/);
284
+ assert.match(result, /<span class="ctx-widget"[^>]*>from-area-with<\/span>/);
285
+ });
286
+
287
+ it('Widget should pass `with={...}` context options through to the widget', async function() {
288
+ const req = apos.task.getAnonReq();
289
+ const widget = {
290
+ _id: 'jsx-ctx-widget-direct-1',
291
+ metaType: 'widget',
292
+ type: 'jsx-ctx'
293
+ };
294
+ const result = await apos.modules['jsx-area-test'].render(req, 'with-widget-ctx', { widget });
295
+ assert.match(result, /data-tag="from-widget-with"/);
296
+ assert.match(result, /<span class="ctx-widget"[^>]*>from-widget-with<\/span>/);
297
+ });
298
+
299
+ // Exercises the full render-time async pipeline: a page JSX template calls
300
+ // <Area>, the area renders each widget via its widget.jsx, and the widget.jsx
301
+ // itself invokes <Component> which runs an async component that delays before
302
+ // returning. If any await is missing along that path — page → Area → widget.jsx
303
+ // → Component — the deferred output ("(after delay)") will not appear in the
304
+ // rendered HTML.
305
+ it('a JSX widget.jsx that calls an async <Component> should resolve through the full render pipeline', async function() {
306
+ const req = apos.task.getAnonReq();
307
+ const piece = {
308
+ _id: 'jsx-async-host',
309
+ metaType: 'doc',
310
+ type: 'jsx-area-test',
311
+ title: 'Async Host',
312
+ slug: 'async-host',
313
+ main: {
314
+ _id: 'jsx-async-area',
315
+ metaType: 'area',
316
+ items: [
317
+ {
318
+ _id: 'jsx-async-widget-instance',
319
+ metaType: 'widget',
320
+ type: 'jsx-async',
321
+ who: 'World'
322
+ }
323
+ ]
324
+ }
325
+ };
326
+ const result = await apos.modules['jsx-area-test'].render(req, 'with-area', { piece });
327
+ assert.match(result, /<div class="async-widget">/);
328
+ assert.match(
329
+ result,
330
+ /<span class="greet">Hello World \(after delay\)<\/span>/
331
+ );
332
+ });
333
+
334
+ // ---- JSX → Nunjucks bridge via Extend ----
335
+
336
+ it('should let JSX extend a Nunjucks layout, mapping props to {% block %} overrides', async function() {
337
+ const req = apos.task.getAnonReq();
338
+ const result = await apos.modules['jsx-bridge-test'].render(req, 'njk-extends', {
339
+ message: 'hello & <world>',
340
+ pageSlug: '/test'
341
+ });
342
+ assert.match(result, /<title>A JSX page<\/title>/);
343
+ assert.match(result, /<body data-page="\/test">/);
344
+ assert.match(result, /<main>/);
345
+ assert.match(result, /<h1>I am from JSX<\/h1>/);
346
+ // Auto-escape applies to ordinary string children inside JSX, even when
347
+ // the surrounding output ends up in a Nunjucks block override.
348
+ assert.match(result, /<p>hello &amp; &lt;world&gt;<\/p>/);
349
+ assert.doesNotMatch(result, /default body/);
350
+ assert.doesNotMatch(result, /default title/);
351
+ });
352
+
353
+ // ---- JSX → JSX layout via Template + children ----
354
+
355
+ it('should let JSX extend a JSX layout, passing children through', async function() {
356
+ const req = apos.task.getAnonReq();
357
+ const result = await apos.modules['jsx-bridge-test'].render(req, 'jsx-extends', {
358
+ message: 'inside the jsx layout'
359
+ });
360
+ assert.match(result, /<title>JSX-in-JSX<\/title>/);
361
+ assert.match(result, /<header>shared header<\/header>/);
362
+ assert.match(result, /<main>/);
363
+ assert.match(result, /<p>inside the jsx layout<\/p>/);
364
+ assert.match(result, /<footer>shared footer<\/footer>/);
365
+ });
366
+
367
+ // ---- <Extend> against a .jsx target behaves like <Template> ----
368
+
369
+ it('Extend against a .jsx target should behave like Template (props + children)', async function() {
370
+ const req = apos.task.getAnonReq();
371
+ const result = await apos.modules['jsx-bridge-test'].render(req, 'jsx-extends-via-extend', {
372
+ message: 'inside via extend'
373
+ });
374
+ assert.match(result, /<title>Extend-against-JSX<\/title>/);
375
+ assert.match(result, /<header>shared header<\/header>/);
376
+ assert.match(result, /<p>inside via extend<\/p>/);
377
+ assert.match(result, /<footer>shared footer<\/footer>/);
378
+ });
379
+
380
+ // ---- <Template> against a .html target: include semantics ----
381
+
382
+ it('Template against a .html target should pass props as data and NOT override blocks', async function() {
383
+ const req = apos.task.getAnonReq();
384
+ const result = await apos.modules['jsx-bridge-test'].render(req, 'include-html');
385
+ // Block content is the default — Template did NOT override it.
386
+ assert.match(result, /<p class="block-output">default-block<\/p>/);
387
+ // The JSX prop arrived as data.content in Nunjucks.
388
+ assert.match(result, /<p class="data-output">prop-content<\/p>/);
389
+ });
390
+
391
+ // ---- Template short-form `name=` (no `templateName`) ----
392
+
393
+ it('Template `name=` short form should resolve and pass other props as data', async function() {
394
+ const req = apos.task.getAnonReq();
395
+ const result = await apos.modules['jsx-bridge-test'].render(req, 'short-form');
396
+ assert.match(result, /<p>short:from-short<\/p>/);
397
+ });
398
+
399
+ // ---- Cross-module `module:file` ----
400
+
401
+ it('Template should resolve `module:file` cross-module references', async function() {
402
+ const req = apos.task.getAnonReq();
403
+ const result = await apos.modules['jsx-bridge-test'].render(req, 'cross-module');
404
+ assert.match(result, /<p class="partial">partial:from-cross-module<\/p>/);
405
+ });
406
+
407
+ // ---- templateName/name disambiguation rule ----
408
+
409
+ it('Template with both `templateName` and `name` should forward `name` as a prop and not `templateName`', async function() {
410
+ const req = apos.task.getAnonReq();
411
+ const result = await apos.modules['jsx-bridge-test'].render(req, 'disambig-with-template-name');
412
+ // `name` is forwarded as data.name when `templateName` is also present.
413
+ assert.match(result, /data-name="forwarded-value"/);
414
+ // `templateName` itself is never forwarded as a data prop.
415
+ assert.match(result, /data-template-name="undefined"/);
416
+ });
417
+
418
+ it('Template with only `name=` should use it as the selector and not forward it as data', async function() {
419
+ const req = apos.task.getAnonReq();
420
+ const result = await apos.modules['jsx-bridge-test'].render(req, 'disambig-name-only');
421
+ // `name` was the selector (resolved disambig-target.jsx), not a prop.
422
+ assert.match(result, /data-name="undefined"/);
423
+ // The target rendered, proving `name` selected it.
424
+ assert.match(result, /data-template-name="undefined"/);
425
+ });
426
+
427
+ // ---- import / require inside .jsx ----
428
+
429
+ it('should support `import` inside .jsx files', async function() {
430
+ const req = apos.task.getAnonReq();
431
+ const result = await apos.modules['jsx-mixed-test'].render(req, 'uses-import', {
432
+ value: 'imported'
433
+ });
434
+ assert.match(result, /<span>\[imported\]<\/span>/);
435
+ });
436
+
437
+ it('should support `require` inside .jsx files', async function() {
438
+ const req = apos.task.getAnonReq();
439
+ const result = await apos.modules['jsx-mixed-test'].render(req, 'uses-require', {
440
+ value: 'required'
441
+ });
442
+ assert.match(result, /<span>\[required\]<\/span>/);
443
+ });
444
+
445
+ // ---- Async JSX with promise-returning helpers in arrays ----
446
+
447
+ it('should await promises returned from inside arrays', async function() {
448
+ const req = apos.task.getAnonReq();
449
+ const result = await apos.modules['jsx-mixed-test'].render(req, 'async-list', {
450
+ items: [ 'one', 'two', 'three' ]
451
+ });
452
+ assert.match(result, /<ul><li>one<\/li><li>two<\/li><li>three<\/li><\/ul>/);
453
+ });
454
+
455
+ // ---- Nunjucks SafeString interop ----
456
+
457
+ it('should not double-escape Nunjucks SafeString values returned by helpers', async function() {
458
+ const req = apos.task.getAnonReq();
459
+ const result = await apos.modules['jsx-mixed-test'].render(req, 'safe-helper');
460
+ // Helper escapes its input itself, returns SafeString. JSX must not
461
+ // re-escape the resulting <b> tag, but the inner text was escaped by
462
+ // the helper.
463
+ assert.match(result, /<div><b>hello &amp; &lt;world&gt;<\/b><\/div>/);
464
+ });
465
+
466
+ // ---- Full self.apos exposure ----
467
+
468
+ it('should expose full self.apos as `apos` and templateApos as `helpers` (distinct objects)', async function() {
469
+ const req = apos.task.getAnonReq();
470
+ const result = await apos.modules['jsx-mixed-test'].render(req, 'apos-full', { req });
471
+ // apos.util.generateId() lives on self.apos.util, not on templateApos.
472
+ assert.match(result, /data-id-length="\d+"/);
473
+ // apos.doc.find(req, ...) returns a real cursor we can await.
474
+ assert.match(result, /data-global-count="\d+"/);
475
+ // apos and helpers must be different objects, not aliases.
476
+ assert.match(result, /data-distinct="true"/);
477
+ // apos.doc is the doc-module instance, with module methods like .find().
478
+ assert.match(result, /data-apos-doc-is-module="true"/);
479
+ // It is a different object than the helper bag for @apostrophecms/doc.
480
+ assert.match(result, /data-helpers-doc-is-helper-bag="true"/);
481
+ // The `modules` collections on the two objects are distinct.
482
+ assert.match(result, /data-modules-distinct="true"/);
483
+ });
484
+
485
+ // ---- Localization helper ----
486
+
487
+ it('should expose __t as the localization helper', async function() {
488
+ const req = apos.task.getAnonReq();
489
+ const result = await apos.modules['jsx-mixed-test'].render(req, 'localized');
490
+ assert.match(result, /<p>404 - Page not found<\/p>/);
491
+ });
492
+
493
+ // ---- Resolution: prefer .jsx when it exists alongside .html ----
494
+
495
+ it('resolveTemplate should prefer .jsx over .html in the same directory', function() {
496
+ const module = apos.modules['template-jsx-test'];
497
+ const resolved = apos.template.resolveTemplate(module, 'test');
498
+ assert.equal(resolved.kind, 'jsx');
499
+ assert.equal(resolved.ext, 'jsx');
500
+ });
501
+
502
+ it('resolveTemplate should still find .html templates when no .jsx exists', function() {
503
+ // template-test only has .html, not .jsx
504
+ const module = apos.modules['@apostrophecms/template'];
505
+ const resolved = apos.template.resolveTemplate(module, 'outerLayout');
506
+ assert.equal(resolved.kind, 'nunjucks');
507
+ assert.equal(resolved.ext, 'html');
508
+ });
509
+
510
+ // ---- Error handling ----
511
+
512
+ it('should annotate runtime errors thrown by JSX templates with the file path', async function() {
513
+ const req = apos.task.getAnonReq();
514
+ let caught;
515
+ try {
516
+ await apos.modules['jsx-mixed-test'].render(req, 'throws');
517
+ } catch (e) {
518
+ caught = e;
519
+ }
520
+ assert(caught, 'expected the render to throw');
521
+ assert.match(caught.message, /\[JSX template .*throws\.jsx\]/);
522
+ assert.equal(caught.aposJsxFile.endsWith('throws.jsx'), true);
523
+ });
524
+
525
+ it('should report JSX compile errors with the file path and a clear code', async function() {
526
+ const req = apos.task.getAnonReq();
527
+ let caught;
528
+ try {
529
+ await apos.modules['jsx-mixed-test'].render(req, 'syntax-error');
530
+ } catch (e) {
531
+ caught = e;
532
+ }
533
+ assert(caught, 'expected the render to throw');
534
+ assert.match(caught.message, /syntax-error\.jsx/);
535
+ });
536
+
537
+ });
package/test-lib/util.js CHANGED
@@ -1,5 +1,27 @@
1
1
  const { createId } = require('@paralleldrive/cuid2');
2
- const mongodbConnect = require('../lib/mongodb-connect');
2
+
3
+ const testDbProtocol = process.env.APOS_TEST_DB_PROTOCOL || 'mongodb';
4
+
5
+ // Build a test database URI for postgres based on the shortName.
6
+ // Returns undefined for mongodb, letting the default logic handle it.
7
+ function getTestDbUri(shortName) {
8
+ if (testDbProtocol === 'postgres') {
9
+ // PostgreSQL database names cannot contain hyphens
10
+ const dbName = shortName.replace(/-/g, '_');
11
+ return `postgres://localhost:5432/${dbName}`;
12
+ }
13
+ if (testDbProtocol === 'multipostgres') {
14
+ // Multi-schema mode: shared real database, per-test schema
15
+ const schemaName = shortName.replace(/-/g, '_').replace(/[^a-zA-Z0-9_]/g, '');
16
+ return `multipostgres://localhost:5432/apos_test-${schemaName}`;
17
+ }
18
+ if (testDbProtocol === 'sqlite') {
19
+ const os = require('os');
20
+ const path = require('path');
21
+ const dbName = shortName.replace(/-/g, '_').replace(/[^a-zA-Z0-9_]/g, '');
22
+ return `sqlite://${path.join(os.tmpdir(), `apos_test_${dbName}.db`)}`;
23
+ }
24
+ }
3
25
 
4
26
  // Properly clean up an apostrophe instance and drop its
5
27
  // database collections to create a sane environment for the next test.
@@ -10,23 +32,23 @@ const mongodbConnect = require('../lib/mongodb-connect');
10
32
  // If `apos` is null, no work is done.
11
33
 
12
34
  async function destroy(apos) {
13
- if (!apos) {
35
+ if (!apos || apos._destroyed) {
14
36
  return;
15
37
  }
38
+ apos._destroyed = true;
39
+ const dbModule = apos.modules['@apostrophecms/db'];
40
+ const { uri } = dbModule;
41
+ const dbName = apos.db && (apos.db.databaseName || apos.db._name);
16
42
  await apos.destroy();
17
- const { uri } = apos.modules['@apostrophecms/db'];
18
- const dbName = apos.db && apos.db.databaseName;
19
- // TODO at some point accommodate nonsense like testing remote databases
20
- // that won't let us use dropDatabase, no shell available etc., but the
21
- // important principle here is that we should not have to have an apos
22
- // object to clean up the database, otherwise we have to get hold of one
23
- // when initialization failed and that's really not apostrophe's concern
24
- if (dbName && uri) {
25
- const client = await mongodbConnect(`${uri}${dbName}`);
26
- const db = client.db(dbName);
27
- await db.dropDatabase();
28
- await client.close();
43
+ if (!uri || !dbName) {
44
+ return;
29
45
  }
46
+ // Make a fresh connection (the original was closed by destroy)
47
+ // and use it to drop the test database
48
+ const client = await dbModule.connectToAdapter(uri);
49
+ const db = client.db(dbName);
50
+ await db.dropDatabase();
51
+ await client.close();
30
52
  };
31
53
 
32
54
  async function create(options = {}) {
@@ -55,6 +77,18 @@ async function create(options = {}) {
55
77
  express.options.session.secret = express.options.session.secret || 'test';
56
78
  config.modules['@apostrophecms/express'] = express;
57
79
  }
80
+ // When APOS_TEST_DB_PROTOCOL=postgres, automatically configure the db
81
+ // module to use a postgres URI unless already explicitly configured
82
+ const testUri = getTestDbUri(config.shortName);
83
+ if (testUri) {
84
+ config.modules = config.modules || {};
85
+ const dbModule = config.modules['@apostrophecms/db'] || {};
86
+ dbModule.options = dbModule.options || {};
87
+ if (!dbModule.options.uri && !dbModule.options.client) {
88
+ dbModule.options.uri = testUri;
89
+ }
90
+ config.modules['@apostrophecms/db'] = dbModule;
91
+ }
58
92
  return require('../index.js')(config);
59
93
  }
60
94
 
@@ -151,5 +185,7 @@ module.exports = {
151
185
  loginAs,
152
186
  logout,
153
187
  getUserJar,
188
+ getTestDbUri,
189
+ testDbProtocol,
154
190
  timeout: (process.env.TEST_TIMEOUT && parseInt(process.env.TEST_TIMEOUT)) || 20000
155
191
  };
@@ -1,15 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(timeout 180 npx mocha:*)",
5
- "Bash(timeout 600 npx mocha:*)",
6
- "Bash(npm ls:*)",
7
- "Bash(timeout 540 npx mocha:*)",
8
- "Bash(echo:*)",
9
- "Bash(timeout 10 node:*)",
10
- "Bash(timeout 300 npx mocha:*)",
11
- "Bash(timeout 60 npx mocha:*)",
12
- "Bash(timeout 120 npx mocha:*)"
13
- ]
14
- }
15
- }