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.
Files changed (35) hide show
  1. package/CHANGELOG.md +43 -2
  2. package/claude-tools/run-mocha-file.sh +29 -0
  3. package/modules/@apostrophecms/area/index.js +14 -5
  4. package/modules/@apostrophecms/area/ui/apos/apps/AposAreas.js +72 -3
  5. package/modules/@apostrophecms/asset/index.js +170 -21
  6. package/modules/@apostrophecms/asset/lib/build/external-module-api.js +7 -16
  7. package/modules/@apostrophecms/asset/lib/build/internals.js +1 -3
  8. package/modules/@apostrophecms/asset/lib/build/task.js +7 -13
  9. package/modules/@apostrophecms/command-menu/ui/apos/components/TheAposCommandMenu.vue +21 -1
  10. package/modules/@apostrophecms/doc-type/index.js +73 -15
  11. package/modules/@apostrophecms/http/index.js +175 -139
  12. package/modules/@apostrophecms/image/index.js +16 -7
  13. package/modules/@apostrophecms/image/ui/apos/components/AposMediaManager.vue +9 -1
  14. package/modules/@apostrophecms/job/index.js +3 -1
  15. package/modules/@apostrophecms/oembed/index.js +12 -45
  16. package/modules/@apostrophecms/page/index.js +17 -1
  17. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposRichTextWidgetEditor.vue +10 -1
  18. package/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposTiptapInsertItem.vue +3 -14
  19. package/modules/@apostrophecms/rich-text-widget/ui/apos/lib/remove-slash.js +18 -0
  20. package/modules/@apostrophecms/schema/ui/apos/components/AposInputDateAndTime.vue +37 -6
  21. package/modules/@apostrophecms/url/index.js +54 -0
  22. package/package.json +11 -12
  23. package/test/asset-external.js +3 -0
  24. package/test/asset-lock-cache.js +243 -0
  25. package/test/assets.js +25 -2
  26. package/test/config-cascade-merge.js +73 -0
  27. package/test/files.js +10 -11
  28. package/test/image-tags.js +103 -0
  29. package/test/modules/collision-piece/index.js +23 -0
  30. package/test/oembed.js +0 -98
  31. package/test/pages-move-permissions.js +317 -0
  32. package/test/pages.js +12 -15
  33. package/test/pieces-public-api-relationship-choices.js +245 -0
  34. package/test/pieces.js +12 -15
  35. 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. Failures are cached as well, so
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
- const cached = await self.apos.cache.get('@apostrophecms/oembed', key);
118
- if (cached !== undefined) {
119
- // Cache entries are wrapped so that both successes and failures can
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
- let response;
135
- try {
136
- if (options.alwaysIframe) {
137
- response = await self.iframe(req, url, options);
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
- // Cache the successful response (by default for one hour; see the
169
- // cacheLifetime option).
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' && !parent._edit)
1674
+ !(oldParent.type === '@apostrophecms/archive-page' && parent._edit)
1659
1675
  ) {
1660
1676
  throw self.apos.error('forbidden');
1661
1677
  }
@@ -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
- const state = props.editor.state;
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
- display: flex;
53
+ container-type: inline-size;
54
+ display: grid;
55
+ grid-template-columns: auto minmax(0, 1fr);
54
56
  align-items: center;
55
- justify-content: space-around;
57
+ gap: 8px 12px;
56
58
  }
57
59
 
58
60
  .apos-toggle {
59
- margin-right: 12px;
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
- &--label {
72
- margin: 0 12px;
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.31.1-beta.1",
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": ">=16.0.0"
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": "^10.4.5",
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
- "node-fetch": "^2.6.1",
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
- "oembetter": "^1.2.0",
130
- "sanitize-html": "^2.17.5",
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
- "stylelint-config-apostrophe": "^4.4.0",
141
- "eslint-config-apostrophe": "^6.0.2"
139
+ "eslint-config-apostrophe": "^6.0.2",
140
+ "stylelint-config-apostrophe": "^4.4.0"
142
141
  },
143
142
  "browserslist": [
144
143
  "ie >= 10"
@@ -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
+ });