apostrophe 4.31.1-beta.1 → 4.32.0
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.
- package/CHANGELOG.md +43 -2
- package/claude-tools/run-mocha-file.sh +29 -0
- package/modules/@apostrophecms/area/index.js +14 -5
- package/modules/@apostrophecms/area/ui/apos/apps/AposAreas.js +72 -3
- package/modules/@apostrophecms/asset/index.js +170 -21
- package/modules/@apostrophecms/asset/lib/build/external-module-api.js +7 -16
- package/modules/@apostrophecms/asset/lib/build/internals.js +1 -3
- package/modules/@apostrophecms/asset/lib/build/task.js +7 -13
- package/modules/@apostrophecms/command-menu/ui/apos/components/TheAposCommandMenu.vue +21 -1
- package/modules/@apostrophecms/doc-type/index.js +73 -15
- package/modules/@apostrophecms/http/index.js +175 -139
- package/modules/@apostrophecms/image/index.js +16 -7
- package/modules/@apostrophecms/image/ui/apos/components/AposMediaManager.vue +9 -1
- package/modules/@apostrophecms/job/index.js +3 -1
- package/modules/@apostrophecms/oembed/index.js +12 -45
- package/modules/@apostrophecms/page/index.js +17 -1
- package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposRichTextWidgetEditor.vue +10 -1
- package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposTiptapInsertItem.vue +3 -14
- package/modules/@apostrophecms/rich-text-widget/ui/apos/lib/remove-slash.js +18 -0
- package/modules/@apostrophecms/schema/ui/apos/components/AposInputDateAndTime.vue +37 -6
- package/modules/@apostrophecms/url/index.js +54 -0
- package/package.json +11 -12
- package/test/asset-external.js +3 -0
- package/test/asset-lock-cache.js +243 -0
- package/test/assets.js +25 -2
- package/test/config-cascade-merge.js +73 -0
- package/test/files.js +10 -11
- package/test/image-tags.js +103 -0
- package/test/modules/collision-piece/index.js +23 -0
- package/test/oembed.js +0 -98
- package/test/pages-move-permissions.js +317 -0
- package/test/pages.js +12 -15
- package/test/pieces-public-api-relationship-choices.js +245 -0
- package/test/pieces.js +12 -15
- package/test-lib/test.js +32 -0
|
@@ -86,9 +86,7 @@ module.exports = {
|
|
|
86
86
|
// The `options` object is passed on to `oembetter.fetch`.
|
|
87
87
|
//
|
|
88
88
|
// Responses are automatically cached, by default for one hour. See the
|
|
89
|
-
// cacheLifetime option to the module.
|
|
90
|
-
// that a bad or removed URL does not lead to repeated requests that can
|
|
91
|
-
// trigger rate-limiting (e.g. YouTube 429 errors and lockouts).
|
|
89
|
+
// cacheLifetime option to the module.
|
|
92
90
|
async query(req, url, options) {
|
|
93
91
|
if (!options) {
|
|
94
92
|
options = {};
|
|
@@ -114,44 +112,18 @@ module.exports = {
|
|
|
114
112
|
throw self.apos.error('invalid', req.t('apostrophe:oembedVideoUrlInvalid'));
|
|
115
113
|
}
|
|
116
114
|
const key = url + ':' + JSON.stringify(options);
|
|
117
|
-
|
|
118
|
-
if (
|
|
119
|
-
|
|
120
|
-
// be remembered. Caching failures prevents a bad or removed URL
|
|
121
|
-
// (such as a deleted YouTube video) from being requested over and
|
|
122
|
-
// over, which can trigger 429 rate-limiting and temporary lockouts.
|
|
123
|
-
//
|
|
124
|
-
// Legacy entries written before this wrapping existed are the raw
|
|
125
|
-
// oembed response object, so tolerate them by returning them as-is.
|
|
126
|
-
if (cached && cached.aposOembedCache) {
|
|
127
|
-
if (cached.error) {
|
|
128
|
-
throw self.apos.error('invalid', req.t('apostrophe:oembedVideoUrlInvalid'));
|
|
129
|
-
}
|
|
130
|
-
return cached.response;
|
|
131
|
-
}
|
|
132
|
-
return cached;
|
|
115
|
+
let response = await self.apos.cache.get('@apostrophecms/oembed', key);
|
|
116
|
+
if (response !== undefined) {
|
|
117
|
+
return response;
|
|
133
118
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
} else {
|
|
119
|
+
if (options.alwaysIframe) {
|
|
120
|
+
response = await self.iframe(req, url, options);
|
|
121
|
+
} else {
|
|
122
|
+
try {
|
|
139
123
|
response = await require('util').promisify(self.oembetter.fetch)(url, options);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
throw self.apos.error('invalid', req.t('apostrophe:oembedVideoUrlInvalid'));
|
|
140
126
|
}
|
|
141
|
-
} catch (err) {
|
|
142
|
-
// Log the underlying cause first, since the higher-level error
|
|
143
|
-
// thrown below would otherwise obscure it.
|
|
144
|
-
self.logError(req, 'query-failed', err.message, {
|
|
145
|
-
url,
|
|
146
|
-
stack: err.stack
|
|
147
|
-
});
|
|
148
|
-
// Cache the failure too, so we don't keep hammering the provider
|
|
149
|
-
// for a URL that is currently failing.
|
|
150
|
-
await self.apos.cache.set('@apostrophecms/oembed', key, {
|
|
151
|
-
aposOembedCache: true,
|
|
152
|
-
error: true
|
|
153
|
-
}, self.options.cacheLifetime);
|
|
154
|
-
throw self.apos.error('invalid', req.t('apostrophe:oembedVideoUrlInvalid'));
|
|
155
127
|
}
|
|
156
128
|
// Make non-secure URLs protocol relative and
|
|
157
129
|
// let the browser upgrade them to https if needed
|
|
@@ -165,13 +137,8 @@ module.exports = {
|
|
|
165
137
|
if (response.html) {
|
|
166
138
|
response.html = makeProtocolRelative(response.html);
|
|
167
139
|
}
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
await self.apos.cache.set('@apostrophecms/oembed', key, {
|
|
171
|
-
aposOembedCache: true,
|
|
172
|
-
error: false,
|
|
173
|
-
response
|
|
174
|
-
}, self.options.cacheLifetime);
|
|
140
|
+
// cache oembed responses for one hour
|
|
141
|
+
await self.apos.cache.set('@apostrophecms/oembed', key, response, self.options.cacheLifetime);
|
|
175
142
|
return response;
|
|
176
143
|
},
|
|
177
144
|
// Not currently used. Present for backwards compatibility
|
|
@@ -1651,11 +1651,27 @@ database.`);
|
|
|
1651
1651
|
// Move outside tree
|
|
1652
1652
|
throw self.apos.error('forbidden');
|
|
1653
1653
|
}
|
|
1654
|
+
// Enforce destination-parent authorization: a cross-parent move
|
|
1655
|
+
// into a non-archive destination requires "create" permission on
|
|
1656
|
+
// that destination (the same boundary the page-insert route
|
|
1657
|
+
// enforces). The one exception is restoring a page out of the
|
|
1658
|
+
// archive, which is permitted into any destination the actor may
|
|
1659
|
+
// edit even without "create".
|
|
1660
|
+
//
|
|
1661
|
+
// That exception is NOT dead code: with @apostrophecms-pro/advanced-
|
|
1662
|
+
// permission, per-document permissions grant edit (and view/publish)
|
|
1663
|
+
// independently of type-level create, so a page can legitimately have
|
|
1664
|
+
// _edit true while _create is false. Do not "simplify" this away.
|
|
1665
|
+
//
|
|
1666
|
+
// The exception must be negated and ANDed onto the guard, NOT ANDed
|
|
1667
|
+
// on unnegated: doing the latter (a past regression, GHSA-wr5r-wqp2-
|
|
1668
|
+
// x4fh) gated the whole check on "moving out of the archive" and
|
|
1669
|
+
// disabled create enforcement for every normal move.
|
|
1654
1670
|
if (
|
|
1655
1671
|
(oldParent._id !== parent._id) &&
|
|
1656
1672
|
(parent.type !== '@apostrophecms/archive-page') &&
|
|
1657
1673
|
(!parent._create) &&
|
|
1658
|
-
(oldParent.type === '@apostrophecms/archive-page' &&
|
|
1674
|
+
!(oldParent.type === '@apostrophecms/archive-page' && parent._edit)
|
|
1659
1675
|
) {
|
|
1660
1676
|
throw self.apos.error('forbidden');
|
|
1661
1677
|
}
|
package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposRichTextWidgetEditor.vue
CHANGED
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
:editor="editor"
|
|
53
53
|
role="listbox"
|
|
54
54
|
tabindex="0"
|
|
55
|
+
@keydown="closeInsertMenu"
|
|
55
56
|
>
|
|
56
57
|
<div class="apos-rich-text-insert-menu-heading">
|
|
57
58
|
{{ $t('apostrophe:richTextInsertMenuHeading') }}
|
|
@@ -60,7 +61,6 @@
|
|
|
60
61
|
class="apos-rich-text-insert-menu-wrapper"
|
|
61
62
|
@keydown.prevent.arrow-up="focusInsertMenuItem(true)"
|
|
62
63
|
@keydown.prevent.arrow-down="focusInsertMenuItem()"
|
|
63
|
-
@keydown="closeInsertMenu"
|
|
64
64
|
>
|
|
65
65
|
<li
|
|
66
66
|
v-for="(item, index) in insert"
|
|
@@ -152,6 +152,7 @@ import merge from 'lodash/merge';
|
|
|
152
152
|
import { useAposStyles } from 'Modules/@apostrophecms/styles/composables/AposStyles.js';
|
|
153
153
|
import { useModalStore } from 'Modules/@apostrophecms/ui/stores/modal';
|
|
154
154
|
import { useWidgetStore } from 'Modules/@apostrophecms/ui/stores/widget';
|
|
155
|
+
import removeSlash from 'Modules/@apostrophecms/rich-text-widget/lib/remove-slash.js';
|
|
155
156
|
|
|
156
157
|
export default {
|
|
157
158
|
name: 'AposRichTextWidgetEditor',
|
|
@@ -837,6 +838,14 @@ export default {
|
|
|
837
838
|
) {
|
|
838
839
|
return;
|
|
839
840
|
}
|
|
841
|
+
if (e.key === 'Backspace') {
|
|
842
|
+
// Don't let the global remove-widget shortcut see this key
|
|
843
|
+
e.preventDefault();
|
|
844
|
+
e.stopPropagation();
|
|
845
|
+
removeSlash(this.editor);
|
|
846
|
+
this.editor.commands.focus();
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
840
849
|
this.editor.commands.focus();
|
|
841
850
|
this.activeInsertMenuComponent = false;
|
|
842
851
|
// Only insert character keys
|
|
@@ -44,6 +44,8 @@
|
|
|
44
44
|
|
|
45
45
|
<script setup>
|
|
46
46
|
import { ref, computed } from 'vue';
|
|
47
|
+
import removeEditorSlash from 'Modules/@apostrophecms/rich-text-widget/lib/remove-slash.js';
|
|
48
|
+
|
|
47
49
|
const props = defineProps({
|
|
48
50
|
name: {
|
|
49
51
|
type: String,
|
|
@@ -87,20 +89,7 @@ function activate() {
|
|
|
87
89
|
}
|
|
88
90
|
|
|
89
91
|
function removeSlash() {
|
|
90
|
-
|
|
91
|
-
const { $to } = state.selection;
|
|
92
|
-
if (state.selection.empty && $to?.nodeBefore?.text) {
|
|
93
|
-
const text = $to.nodeBefore.text;
|
|
94
|
-
if (text.slice(-1) === '/') {
|
|
95
|
-
const pos = props.editor.view.state.selection.$anchor.pos;
|
|
96
|
-
// Select the slash so an insert operation can replace it
|
|
97
|
-
props.editor.commands.setTextSelection({
|
|
98
|
-
from: pos - 1,
|
|
99
|
-
to: pos
|
|
100
|
-
});
|
|
101
|
-
props.editor.commands.deleteSelection();
|
|
102
|
-
}
|
|
103
|
-
}
|
|
92
|
+
removeEditorSlash(props.editor);
|
|
104
93
|
}
|
|
105
94
|
|
|
106
95
|
function closeInsertMenuItem() {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Delete the trailing "/" that opened the rich text insert menu.
|
|
2
|
+
|
|
3
|
+
export default function removeSlash(editor) {
|
|
4
|
+
const state = editor.state;
|
|
5
|
+
const { $to } = state.selection;
|
|
6
|
+
if (state.selection.empty && $to?.nodeBefore?.text) {
|
|
7
|
+
const text = $to.nodeBefore.text;
|
|
8
|
+
if (text.slice(-1) === '/') {
|
|
9
|
+
const pos = editor.view.state.selection.$anchor.pos;
|
|
10
|
+
// Select the slash so an insert operation can replace it
|
|
11
|
+
editor.commands.setTextSelection({
|
|
12
|
+
from: pos - 1,
|
|
13
|
+
to: pos
|
|
14
|
+
});
|
|
15
|
+
editor.commands.deleteSelection();
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
>
|
|
25
25
|
<span
|
|
26
26
|
:id="`${uid}-at`"
|
|
27
|
-
class="apos-input--label"
|
|
27
|
+
class="apos-input--label apos-input--at"
|
|
28
28
|
>
|
|
29
29
|
{{ $t('apostrophe:at') }}
|
|
30
30
|
</span>
|
|
@@ -50,27 +50,58 @@ export default {
|
|
|
50
50
|
</script>
|
|
51
51
|
<style scoped lang='scss'>
|
|
52
52
|
.apos-input-wrapper {
|
|
53
|
-
|
|
53
|
+
container-type: inline-size;
|
|
54
|
+
display: grid;
|
|
55
|
+
grid-template-columns: auto minmax(0, 1fr);
|
|
54
56
|
align-items: center;
|
|
55
|
-
|
|
57
|
+
gap: 8px 12px;
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
.apos-toggle {
|
|
59
|
-
|
|
61
|
+
grid-row: 1;
|
|
62
|
+
grid-column: 1;
|
|
60
63
|
}
|
|
61
64
|
|
|
62
65
|
.apos-input {
|
|
63
66
|
padding: 10px;
|
|
64
67
|
|
|
68
|
+
&--date,
|
|
69
|
+
&--time {
|
|
70
|
+
grid-column: 2;
|
|
71
|
+
min-width: 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
&--date {
|
|
75
|
+
grid-row: 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
&--time {
|
|
79
|
+
grid-row: 2;
|
|
80
|
+
}
|
|
81
|
+
|
|
65
82
|
&--disabled {
|
|
66
83
|
background-color: var(--a-white);
|
|
67
84
|
border-color: var(--a-base-8);
|
|
68
85
|
color: var(--a-base-2);
|
|
69
86
|
}
|
|
70
87
|
|
|
71
|
-
&--
|
|
72
|
-
|
|
88
|
+
&--at {
|
|
89
|
+
grid-row: 2;
|
|
90
|
+
grid-column: 1;
|
|
91
|
+
justify-self: center;
|
|
73
92
|
font-family: var(--a-family-default);
|
|
74
93
|
}
|
|
75
94
|
}
|
|
95
|
+
|
|
96
|
+
@container (min-width: 280px) {
|
|
97
|
+
.apos-input--at {
|
|
98
|
+
grid-row: 1;
|
|
99
|
+
grid-column: 3;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.apos-input--time {
|
|
103
|
+
grid-row: 1;
|
|
104
|
+
grid-column: 4;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
76
107
|
</style>
|
|
@@ -58,6 +58,25 @@ module.exports = {
|
|
|
58
58
|
};
|
|
59
59
|
},
|
|
60
60
|
|
|
61
|
+
apiRoutes(self) {
|
|
62
|
+
return {
|
|
63
|
+
get: {
|
|
64
|
+
// GET /api/v1/@apostrophecms/url/literal-routes
|
|
65
|
+
//
|
|
66
|
+
// Returns `{ patterns: [ ... ] }`.
|
|
67
|
+
// See the `getLiteralContentRoutes` method.
|
|
68
|
+
'literal-routes': async (req) => {
|
|
69
|
+
if (!self.isExternalFront(req)) {
|
|
70
|
+
throw self.apos.error('forbidden');
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
patterns: await self.getLiteralContentRoutes(req)
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
|
|
61
80
|
handlers(self) {
|
|
62
81
|
return {
|
|
63
82
|
'@apostrophecms/page:beforeSend': {
|
|
@@ -94,6 +113,41 @@ module.exports = {
|
|
|
94
113
|
return !!req.aposExternalFront;
|
|
95
114
|
},
|
|
96
115
|
|
|
116
|
+
// Returns the list of "literal content" route patterns declared by
|
|
117
|
+
// modules — URLs that serve non-page content (e.g. `/robots.txt`,
|
|
118
|
+
// `/sitemap.xml`, `/sitemaps/*`) and must be proxied raw by an external
|
|
119
|
+
// front rather than routed through its page renderer.
|
|
120
|
+
//
|
|
121
|
+
// Emits `@apostrophecms/url:getLiteralContentRoutes`; handlers in any
|
|
122
|
+
// module push prefix-free path patterns onto the `patterns` array
|
|
123
|
+
// (exact like `/robots.txt`, or glob like `/sitemaps/*`). Patterns are
|
|
124
|
+
// declared unconditionally and independent of `req`: the consumer only
|
|
125
|
+
// needs to know "this path is literal content", and a pattern that 404s
|
|
126
|
+
// is harmless. This differs from `getAllUrlMetadata`, which enumerates
|
|
127
|
+
// exact, existing URLs per locale for static builds and sitemaps.
|
|
128
|
+
//
|
|
129
|
+
// Example module level implementation:
|
|
130
|
+
// ```js
|
|
131
|
+
// handlers(self) {
|
|
132
|
+
// return {
|
|
133
|
+
// '@apostrophecms/url:getLiteralContentRoutes': {
|
|
134
|
+
// addSitemapRoutes(req, patterns) {
|
|
135
|
+
// patterns.push('/sitemap.xml');
|
|
136
|
+
// if (self.options.perLocale) {
|
|
137
|
+
// patterns.push('/sitemaps/*');
|
|
138
|
+
// }
|
|
139
|
+
// }
|
|
140
|
+
// }
|
|
141
|
+
// };
|
|
142
|
+
// }
|
|
143
|
+
// ```
|
|
144
|
+
//
|
|
145
|
+
async getLiteralContentRoutes(req) {
|
|
146
|
+
const patterns = [];
|
|
147
|
+
await self.emit('getLiteralContentRoutes', req, patterns);
|
|
148
|
+
return [ ...new Set(patterns) ];
|
|
149
|
+
},
|
|
150
|
+
|
|
97
151
|
// Returns the effective base URL for the given request.
|
|
98
152
|
//
|
|
99
153
|
// Resolution order:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "apostrophe",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.32.0",
|
|
4
4
|
"description": "The Apostrophe Content Management System.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"repository": {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
},
|
|
11
11
|
"homepage": "https://github.com/apostrophecms/apostrophe/tree/main/packages/apostrophe",
|
|
12
12
|
"engines": {
|
|
13
|
-
"node": ">=
|
|
13
|
+
"node": ">=22.0.0"
|
|
14
14
|
},
|
|
15
15
|
"keywords": [
|
|
16
16
|
"apostrophe",
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"express-bearer-token": "^3.0.0",
|
|
71
71
|
"express-session": "^1.18.2",
|
|
72
72
|
"fs-extra": "^7.0.1",
|
|
73
|
-
"glob": "^
|
|
73
|
+
"glob": "^13.0.0",
|
|
74
74
|
"he": "^1.2.0",
|
|
75
75
|
"html-to-text": "^9.0.5",
|
|
76
76
|
"i18next": "^20.3.2",
|
|
@@ -84,8 +84,7 @@
|
|
|
84
84
|
"minimatch": "^3.1.4",
|
|
85
85
|
"mkdirp": "^0.5.5",
|
|
86
86
|
"multer": "^2.1.1",
|
|
87
|
-
"
|
|
88
|
-
"nodemailer": "^8.0.5",
|
|
87
|
+
"nodemailer": "^9.0.1",
|
|
89
88
|
"nunjucks": "^3.2.1",
|
|
90
89
|
"parseurl": "^1.3.3",
|
|
91
90
|
"passport": "^0.6.0",
|
|
@@ -121,14 +120,14 @@
|
|
|
121
120
|
"webpack-merge": "^5.7.3",
|
|
122
121
|
"xregexp": "^2.0.0",
|
|
123
122
|
"@apostrophecms/db-connect": "^1.0.1",
|
|
124
|
-
"broadband": "^1.1.0",
|
|
125
|
-
"boring": "^1.1.1",
|
|
126
123
|
"express-cache-on-demand": "^1.0.4",
|
|
127
124
|
"launder": "^1.7.1",
|
|
125
|
+
"broadband": "^1.1.0",
|
|
126
|
+
"oembetter": "^1.2.1",
|
|
127
|
+
"boring": "^1.1.1",
|
|
128
128
|
"postcss-viewport-to-container-toggle": "^2.3.0",
|
|
129
|
-
"
|
|
130
|
-
"sanitize-html": "^2.17.
|
|
131
|
-
"uploadfs": "^1.26.1"
|
|
129
|
+
"uploadfs": "^1.27.0",
|
|
130
|
+
"sanitize-html": "^2.17.6"
|
|
132
131
|
},
|
|
133
132
|
"devDependencies": {
|
|
134
133
|
"chai": "^4.3.10",
|
|
@@ -137,8 +136,8 @@
|
|
|
137
136
|
"mocha": "^11.7.5",
|
|
138
137
|
"nyc": "^17.1.0",
|
|
139
138
|
"stylelint": "^16.5.0",
|
|
140
|
-
"
|
|
141
|
-
"
|
|
139
|
+
"eslint-config-apostrophe": "^6.0.2",
|
|
140
|
+
"stylelint-config-apostrophe": "^4.4.0"
|
|
142
141
|
},
|
|
143
142
|
"browserslist": [
|
|
144
143
|
"ie >= 10"
|
package/test/asset-external.js
CHANGED
|
@@ -55,6 +55,7 @@ describe('Asset - External Build', function () {
|
|
|
55
55
|
};
|
|
56
56
|
},
|
|
57
57
|
async watch() { },
|
|
58
|
+
async clearCache() { },
|
|
58
59
|
async startDevServer() {
|
|
59
60
|
actualDevServer = true;
|
|
60
61
|
return {
|
|
@@ -124,6 +125,7 @@ describe('Asset - External Build', function () {
|
|
|
124
125
|
return {
|
|
125
126
|
async build() { },
|
|
126
127
|
async watch() { },
|
|
128
|
+
async clearCache() { },
|
|
127
129
|
async startDevServer() {},
|
|
128
130
|
async entrypoints() {
|
|
129
131
|
return [];
|
|
@@ -291,6 +293,7 @@ describe('Asset - External Build', function () {
|
|
|
291
293
|
};
|
|
292
294
|
},
|
|
293
295
|
async watch() { },
|
|
296
|
+
async clearCache() { },
|
|
294
297
|
async startDevServer() {
|
|
295
298
|
return {
|
|
296
299
|
entrypoints: []
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
const t = require('../test-lib/test.js');
|
|
2
|
+
const assert = require('assert');
|
|
3
|
+
const fs = require('fs-extra');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
// The build skip logic used to rely on the lock file modified time, which is
|
|
7
|
+
// unreliable (fresh checkouts, restored CI/Docker build artifacts, clock
|
|
8
|
+
// skew). When the lock file content changed but its mtime was not newer than a
|
|
9
|
+
// leftover build, the admin UI build was skipped and a stale bundle served.
|
|
10
|
+
// These tests cover the content-hash based detection that forces a rebuild and
|
|
11
|
+
// clears the bundler caches on a lock file change.
|
|
12
|
+
describe('Assets - lock file cache invalidation', function() {
|
|
13
|
+
this.timeout(5 * 60 * 1000);
|
|
14
|
+
|
|
15
|
+
let apos;
|
|
16
|
+
const lockPath = path.join(process.cwd(), 'test/package-lock.json');
|
|
17
|
+
let lockSnapshot;
|
|
18
|
+
|
|
19
|
+
before(async function() {
|
|
20
|
+
lockSnapshot = await fs.readFile(lockPath, 'utf8');
|
|
21
|
+
apos = await t.create({
|
|
22
|
+
root: module,
|
|
23
|
+
modules: {}
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
after(async function() {
|
|
28
|
+
await fs.writeFile(lockPath, lockSnapshot);
|
|
29
|
+
await fs.remove(apos.asset.getLockFileHashPath());
|
|
30
|
+
await t.destroy(apos);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(async function() {
|
|
34
|
+
// Restore the lock file after any test that mutated it.
|
|
35
|
+
await fs.writeFile(lockPath, lockSnapshot);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
beforeEach(function() {
|
|
39
|
+
// Each test starts as if the process had just booted.
|
|
40
|
+
apos.asset.lockFileChecked = false;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
function mutateLock(key) {
|
|
44
|
+
const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
45
|
+
lock[key] = (lock[key] || 0) + 1;
|
|
46
|
+
fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2), 'utf8');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
it('hashes the lock file content, stable across reads', async function() {
|
|
50
|
+
const hash = await apos.asset.getLockFileHash();
|
|
51
|
+
assert(typeof hash === 'string' && hash.length > 0);
|
|
52
|
+
assert.strictEqual(await apos.asset.getLockFileHash(), hash);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('produces a different hash when the lock content changes', async function() {
|
|
56
|
+
const before = await apos.asset.getLockFileHash();
|
|
57
|
+
mutateLock('__test');
|
|
58
|
+
const after = await apos.asset.getLockFileHash();
|
|
59
|
+
assert.notStrictEqual(before, after);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('round-trips the saved lock file hash', async function() {
|
|
63
|
+
await apos.asset.saveLockFileHash('abc123');
|
|
64
|
+
assert.strictEqual(await apos.asset.readSavedLockFileHash(), 'abc123');
|
|
65
|
+
await apos.asset.saveLockFileHash(null);
|
|
66
|
+
assert.strictEqual(await apos.asset.readSavedLockFileHash(), null);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('reports no change when the saved hash matches', async function() {
|
|
70
|
+
await apos.asset.saveLockFileHash(await apos.asset.getLockFileHash());
|
|
71
|
+
const changed = await apos.asset.checkLockFileChanged(
|
|
72
|
+
await apos.asset.getLockFileHash()
|
|
73
|
+
);
|
|
74
|
+
assert.strictEqual(changed, false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('reports a change when content differs', async function() {
|
|
78
|
+
await apos.asset.saveLockFileHash('a-different-old-hash');
|
|
79
|
+
const changed = await apos.asset.checkLockFileChanged(
|
|
80
|
+
await apos.asset.getLockFileHash()
|
|
81
|
+
);
|
|
82
|
+
assert.strictEqual(changed, true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('treats a first build (no saved hash) as a change', async function() {
|
|
86
|
+
await apos.asset.saveLockFileHash(null);
|
|
87
|
+
const changed = await apos.asset.checkLockFileChanged(
|
|
88
|
+
await apos.asset.getLockFileHash()
|
|
89
|
+
);
|
|
90
|
+
assert.strictEqual(changed, true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('runs the lock check once per process', async function() {
|
|
94
|
+
await apos.asset.saveLockFileHash('an-old-hash');
|
|
95
|
+
const first = await apos.asset.checkLockFile();
|
|
96
|
+
assert.strictEqual(first.committed, false);
|
|
97
|
+
assert.strictEqual(first.changed, true);
|
|
98
|
+
await apos.asset.commitLockFileCheck(first);
|
|
99
|
+
|
|
100
|
+
// A watcher-triggered rebuild in the same process reports no change,
|
|
101
|
+
// even if the lock content mutated meanwhile.
|
|
102
|
+
mutateLock('__test-memo');
|
|
103
|
+
const again = await apos.asset.checkLockFile();
|
|
104
|
+
assert.strictEqual(again.committed, true);
|
|
105
|
+
assert.strictEqual(again.changed, false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('reports no change on rebuilds when there is no lock file', async function() {
|
|
109
|
+
const original = apos.asset.getLockFileHash;
|
|
110
|
+
apos.asset.getLockFileHash = async () => null;
|
|
111
|
+
try {
|
|
112
|
+
// No lock file: the first build of the process is forced...
|
|
113
|
+
const first = await apos.asset.checkLockFile();
|
|
114
|
+
assert.strictEqual(first.changed, true);
|
|
115
|
+
assert.strictEqual(first.hash, null);
|
|
116
|
+
await apos.asset.commitLockFileCheck(first);
|
|
117
|
+
// ...but watcher-triggered rebuilds are not.
|
|
118
|
+
const again = await apos.asset.checkLockFile();
|
|
119
|
+
assert.strictEqual(again.committed, true);
|
|
120
|
+
assert.strictEqual(again.changed, false);
|
|
121
|
+
} finally {
|
|
122
|
+
apos.asset.getLockFileHash = original;
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('forces a rebuild when content changed but the lock mtime is older', async function() {
|
|
127
|
+
const tsFile = path.join(
|
|
128
|
+
apos.asset.getBundleRootDir(), 'apos-build-timestamp.txt'
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
// Cold build: establishes the bundle, its timestamp and the saved hash.
|
|
132
|
+
await apos.asset.tasks.build.task({ 'check-apos-build': false });
|
|
133
|
+
const ts1 = parseInt(await fs.readFile(tsFile, 'utf8'), 10);
|
|
134
|
+
assert.strictEqual(
|
|
135
|
+
await apos.asset.readSavedLockFileHash(),
|
|
136
|
+
await apos.asset.getLockFileHash()
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
// Dependency change: the lock content changes, but its mtime is OLDER than
|
|
140
|
+
// the freshly built artifacts (fresh checkout / restored build cache).
|
|
141
|
+
// This happens across process starts, so reset the per-process check.
|
|
142
|
+
apos.asset.lockFileChecked = false;
|
|
143
|
+
mutateLock('__test2');
|
|
144
|
+
const older = new Date(Date.now() - 5 * 60 * 1000);
|
|
145
|
+
await fs.utimes(lockPath, older, older);
|
|
146
|
+
|
|
147
|
+
// The auto build (skip path) must not skip - it should rebuild.
|
|
148
|
+
await apos.asset.tasks.build.task({ 'check-apos-build': true });
|
|
149
|
+
const ts2 = parseInt(await fs.readFile(tsFile, 'utf8'), 10);
|
|
150
|
+
|
|
151
|
+
assert(ts2 > ts1, `expected a rebuild (ts2=${ts2} > ts1=${ts1})`);
|
|
152
|
+
assert.strictEqual(
|
|
153
|
+
await apos.asset.readSavedLockFileHash(),
|
|
154
|
+
await apos.asset.getLockFileHash()
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// The webpack file system cache is keyed on the lock file content, but an
|
|
160
|
+
// external build module (e.g. @apostrophecms/vite) keeps a cache that is not,
|
|
161
|
+
// so it must be cleared explicitly when the lock file changes.
|
|
162
|
+
describe('Assets - build module cache cleared on lock change', function() {
|
|
163
|
+
this.timeout(t.timeout);
|
|
164
|
+
|
|
165
|
+
let apos;
|
|
166
|
+
let clearCalls = 0;
|
|
167
|
+
|
|
168
|
+
before(async function() {
|
|
169
|
+
apos = await t.create({
|
|
170
|
+
root: module,
|
|
171
|
+
autoBuild: false,
|
|
172
|
+
modules: {
|
|
173
|
+
'asset-vite-mock': {
|
|
174
|
+
before: '@apostrophecms/asset',
|
|
175
|
+
handlers(self) {
|
|
176
|
+
return {
|
|
177
|
+
'@apostrophecms/asset:afterInit': {
|
|
178
|
+
async registerExternalBuild() {
|
|
179
|
+
self.apos.asset.configureBuildModule(self, {
|
|
180
|
+
alias: 'vite',
|
|
181
|
+
devServer: false,
|
|
182
|
+
hmr: false
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
},
|
|
188
|
+
methods(self) {
|
|
189
|
+
return {
|
|
190
|
+
async build() {
|
|
191
|
+
return { entrypoints: [] };
|
|
192
|
+
},
|
|
193
|
+
async watch() {},
|
|
194
|
+
async startDevServer() {
|
|
195
|
+
return { entrypoints: [] };
|
|
196
|
+
},
|
|
197
|
+
async entrypoints() {
|
|
198
|
+
return [];
|
|
199
|
+
},
|
|
200
|
+
async clearCache() {
|
|
201
|
+
clearCalls++;
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
after(async function() {
|
|
211
|
+
await fs.remove(apos.asset.getLockFileHashPath());
|
|
212
|
+
await t.destroy(apos);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('clears the build module cache when the lock content changes', async function() {
|
|
216
|
+
await apos.asset.saveLockFileHash('an-old-hash');
|
|
217
|
+
clearCalls = 0;
|
|
218
|
+
const changed = await apos.asset.checkLockFileChanged(
|
|
219
|
+
await apos.asset.getLockFileHash()
|
|
220
|
+
);
|
|
221
|
+
assert.strictEqual(changed, true);
|
|
222
|
+
assert.strictEqual(clearCalls, 1);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('leaves the build module cache alone when the lock is unchanged', async function() {
|
|
226
|
+
await apos.asset.saveLockFileHash(await apos.asset.getLockFileHash());
|
|
227
|
+
clearCalls = 0;
|
|
228
|
+
const changed = await apos.asset.checkLockFileChanged(
|
|
229
|
+
await apos.asset.getLockFileHash()
|
|
230
|
+
);
|
|
231
|
+
assert.strictEqual(changed, false);
|
|
232
|
+
assert.strictEqual(clearCalls, 0);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('forces a rebuild but keeps the cache when no lock file is found', async function() {
|
|
236
|
+
await apos.asset.saveLockFileHash('an-old-hash');
|
|
237
|
+
clearCalls = 0;
|
|
238
|
+
// null hash => no lock file present
|
|
239
|
+
const changed = await apos.asset.checkLockFileChanged(null);
|
|
240
|
+
assert.strictEqual(changed, true);
|
|
241
|
+
assert.strictEqual(clearCalls, 0);
|
|
242
|
+
});
|
|
243
|
+
});
|