@adobe/spacecat-shared-tokowaka-client 1.5.5 → 1.5.7

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 CHANGED
@@ -1,3 +1,18 @@
1
+ # [@adobe/spacecat-shared-tokowaka-client-v1.5.7](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.5.6...@adobe/spacecat-shared-tokowaka-client-v1.5.7) (2026-01-22)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * headings mapper for invalid headings ([#1281](https://github.com/adobe/spacecat-shared/issues/1281)) ([01734df](https://github.com/adobe/spacecat-shared/commit/01734df0de2f9d43bb6c2ee0636449c26d336ca7))
7
+
8
+ # [@adobe/spacecat-shared-tokowaka-client-v1.5.6](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.5.5...@adobe/spacecat-shared-tokowaka-client-v1.5.6) (2026-01-22)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * edge apis, ignore scrapedAt prop for lastUpdated calculation ([#1280](https://github.com/adobe/spacecat-shared/issues/1280)) ([05e4bde](https://github.com/adobe/spacecat-shared/commit/05e4bde07fc865e8d0aee71639f799798e68594d))
14
+ * update prerender config ([#1279](https://github.com/adobe/spacecat-shared/issues/1279)) ([4502ba4](https://github.com/adobe/spacecat-shared/commit/4502ba43f6a26e5d774ce0e2e7dfc6e886ab7402))
15
+
1
16
  # [@adobe/spacecat-shared-tokowaka-client-v1.5.5](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.5.4...@adobe/spacecat-shared-tokowaka-client-v1.5.5) (2026-01-21)
2
17
 
3
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/spacecat-shared-tokowaka-client",
3
- "version": "1.5.5",
3
+ "version": "1.5.7",
4
4
  "description": "Tokowaka Client for SpaceCat - Edge optimization config management",
5
5
  "type": "module",
6
6
  "engines": {
package/src/index.js CHANGED
@@ -355,20 +355,25 @@ class TokowakaClient {
355
355
  ?? existingMetaconfig.forceFail
356
356
  ?? false;
357
357
 
358
+ const hasPrerender = isNonEmptyObject(options.prerender)
359
+ || isNonEmptyObject(existingMetaconfig.prerender);
360
+ const prerender = options.prerender
361
+ ?? existingMetaconfig.prerender;
362
+
358
363
  const metaconfig = {
359
- siteId,
360
- apiKeys: existingMetaconfig.apiKeys,
364
+ ...existingMetaconfig,
361
365
  tokowakaEnabled: options.tokowakaEnabled ?? existingMetaconfig.tokowakaEnabled ?? true,
362
366
  enhancements: options.enhancements ?? existingMetaconfig.enhancements ?? true,
363
367
  patches: isNonEmptyObject(options.patches)
364
368
  ? options.patches
365
369
  : (existingMetaconfig.patches ?? {}),
366
370
  ...(hasForceFail && { forceFail }),
371
+ ...(hasPrerender && { prerender }),
367
372
  };
368
373
 
369
374
  const s3Path = await this.uploadMetaconfig(url, metaconfig);
370
375
 
371
- this.log.info(`Created new Tokowaka metaconfig for ${normalizedHostName} at ${s3Path}`);
376
+ this.log.info(`Updated Tokowaka metaconfig for ${normalizedHostName} at ${s3Path}`);
372
377
 
373
378
  return metaconfig;
374
379
  }
@@ -88,10 +88,7 @@ export default class BaseOpportunityMapper {
88
88
  * @returns {Object} - Base patch object
89
89
  */
90
90
  createBasePatch(suggestion, opportunityId) {
91
- const data = suggestion.getData();
92
- const updatedAt = data?.scrapedAt
93
- || data?.transformRules?.scrapedAt
94
- || suggestion.getUpdatedAt();
91
+ const updatedAt = suggestion.getUpdatedAt();
95
92
 
96
93
  // Parse timestamp, fallback to Date.now() if invalid
97
94
  let lastUpdated = Date.now();
@@ -111,10 +111,7 @@ export default class FaqMapper extends BaseOpportunityMapper {
111
111
  // Calculate the most recent lastUpdated from all eligible suggestions
112
112
  // The heading patch should have the same timestamp as the newest FAQ
113
113
  const maxLastUpdated = Math.max(...eligibleSuggestions.map((suggestion) => {
114
- const data = suggestion.getData();
115
- const updatedAt = data?.scrapedAt
116
- || data?.transformRules?.scrapedAt
117
- || suggestion.getUpdatedAt();
114
+ const updatedAt = suggestion.getUpdatedAt();
118
115
 
119
116
  if (updatedAt) {
120
117
  const parsed = new Date(updatedAt).getTime();
@@ -67,6 +67,10 @@ export default class HeadingsMapper extends BaseOpportunityMapper {
67
67
  patch.tag = transformRules.tag;
68
68
  }
69
69
 
70
+ if (checkType === 'heading-order-invalid') {
71
+ patch.value = transformRules.value;
72
+ patch.valueFormat = transformRules.valueFormat;
73
+ }
70
74
  patches.push(patch);
71
75
  });
72
76
 
@@ -590,7 +590,7 @@ describe('TokowakaClient', () => {
590
590
  });
591
591
 
592
592
  it('should update metaconfig with default options', async () => {
593
- const siteId = 'site-789';
593
+ const siteId = 'site-456';
594
594
  const url = 'https://www.example.com/page1';
595
595
 
596
596
  const result = await client.updateMetaconfig(url, siteId);
@@ -1144,6 +1144,170 @@ describe('TokowakaClient', () => {
1144
1144
 
1145
1145
  expect(result).to.have.property('forceFail', false);
1146
1146
  });
1147
+
1148
+ it('should include prerender when provided in options', async () => {
1149
+ const siteId = 'site-789';
1150
+ const url = 'https://example.com';
1151
+ const prerenderConfig = { allowList: ['/*'] };
1152
+
1153
+ const result = await client.updateMetaconfig(url, siteId, { prerender: prerenderConfig });
1154
+
1155
+ expect(result).to.have.property('prerender');
1156
+ expect(result.prerender).to.deep.equal(prerenderConfig);
1157
+ });
1158
+
1159
+ it('should preserve existingMetaconfig prerender when options.prerender is undefined', async () => {
1160
+ const existingPrerenderConfig = { allowList: ['/*', '/products/*'] };
1161
+ const configWithPrerender = {
1162
+ siteId: 'site-456',
1163
+ apiKeys: ['existing-api-key-123'],
1164
+ tokowakaEnabled: false,
1165
+ enhancements: false,
1166
+ patches: {},
1167
+ prerender: existingPrerenderConfig,
1168
+ };
1169
+ s3Client.send.onFirstCall().resolves({
1170
+ Body: {
1171
+ transformToString: sinon.stub().resolves(JSON.stringify(configWithPrerender)),
1172
+ },
1173
+ });
1174
+
1175
+ const siteId = 'site-789';
1176
+ const url = 'https://example.com';
1177
+
1178
+ const result = await client.updateMetaconfig(url, siteId);
1179
+
1180
+ expect(result).to.have.property('prerender');
1181
+ expect(result.prerender).to.deep.equal(existingPrerenderConfig);
1182
+ });
1183
+
1184
+ it('should use existingMetaconfig prerender when options.prerender is null', async () => {
1185
+ const existingPrerenderConfig = { allowList: ['/*'] };
1186
+ const configWithPrerender = {
1187
+ siteId: 'site-456',
1188
+ apiKeys: ['existing-api-key-123'],
1189
+ tokowakaEnabled: false,
1190
+ enhancements: false,
1191
+ patches: {},
1192
+ prerender: existingPrerenderConfig,
1193
+ };
1194
+ s3Client.send.onFirstCall().resolves({
1195
+ Body: {
1196
+ transformToString: sinon.stub().resolves(JSON.stringify(configWithPrerender)),
1197
+ },
1198
+ });
1199
+
1200
+ const siteId = 'site-789';
1201
+ const url = 'https://example.com';
1202
+
1203
+ const result = await client.updateMetaconfig(url, siteId, { prerender: null });
1204
+
1205
+ expect(result).to.have.property('prerender');
1206
+ expect(result.prerender).to.deep.equal(existingPrerenderConfig);
1207
+ });
1208
+
1209
+ it('should override existingMetaconfig prerender when provided in options', async () => {
1210
+ const existingPrerenderConfig = { allowList: ['/blog/*'] };
1211
+ const newPrerenderConfig = { allowList: ['/*', '/products/*'] };
1212
+ const configWithPrerender = {
1213
+ siteId: 'site-456',
1214
+ apiKeys: ['existing-api-key-123'],
1215
+ tokowakaEnabled: false,
1216
+ enhancements: false,
1217
+ patches: {},
1218
+ prerender: existingPrerenderConfig,
1219
+ };
1220
+ s3Client.send.onFirstCall().resolves({
1221
+ Body: {
1222
+ transformToString: sinon.stub().resolves(JSON.stringify(configWithPrerender)),
1223
+ },
1224
+ });
1225
+
1226
+ const siteId = 'site-789';
1227
+ const url = 'https://example.com';
1228
+
1229
+ const result = await client.updateMetaconfig(url, siteId, { prerender: newPrerenderConfig });
1230
+
1231
+ expect(result).to.have.property('prerender');
1232
+ expect(result.prerender).to.deep.equal(newPrerenderConfig);
1233
+ });
1234
+
1235
+ it('should not include prerender when neither options nor existingMetaconfig have it', async () => {
1236
+ const siteId = 'site-789';
1237
+ const url = 'https://example.com';
1238
+
1239
+ const result = await client.updateMetaconfig(url, siteId);
1240
+
1241
+ expect(result).to.not.have.property('prerender');
1242
+ });
1243
+
1244
+ it('should not include prerender when both options and existingMetaconfig have empty prerender', async () => {
1245
+ const siteId = 'site-789';
1246
+ const url = 'https://example.com';
1247
+
1248
+ const result = await client.updateMetaconfig(url, siteId, { prerender: {} });
1249
+
1250
+ expect(result).to.not.have.property('prerender');
1251
+ });
1252
+
1253
+ it('should include prerender from options when existingMetaconfig does not have it', async () => {
1254
+ const siteId = 'site-789';
1255
+ const url = 'https://example.com';
1256
+ const prerenderConfig = { allowList: ['/*'] };
1257
+
1258
+ const result = await client.updateMetaconfig(url, siteId, { prerender: prerenderConfig });
1259
+
1260
+ expect(result).to.have.property('prerender');
1261
+ expect(result.prerender).to.deep.equal(prerenderConfig);
1262
+ });
1263
+
1264
+ it('should handle prerender with multiple paths in allowList', async () => {
1265
+ const siteId = 'site-789';
1266
+ const url = 'https://example.com';
1267
+ const prerenderConfig = {
1268
+ allowList: ['/*', '/products/*', '/blog/*', '/about'],
1269
+ };
1270
+
1271
+ const result = await client.updateMetaconfig(url, siteId, { prerender: prerenderConfig });
1272
+
1273
+ expect(result).to.have.property('prerender');
1274
+ expect(result.prerender).to.deep.equal(prerenderConfig);
1275
+ });
1276
+
1277
+ it('should use options.prerender even when it is an empty object if existingMetaconfig has no prerender', async () => {
1278
+ const siteId = 'site-789';
1279
+ const url = 'https://example.com';
1280
+
1281
+ const result = await client.updateMetaconfig(url, siteId, { prerender: {} });
1282
+
1283
+ // Empty object is not null/undefined, so it will be used by nullish coalescing
1284
+ // But hasPrerender will be false, so it won't be included in final metaconfig
1285
+ expect(result).to.not.have.property('prerender');
1286
+ });
1287
+
1288
+ it('should handle case where existingMetaconfig.prerender is undefined and options.prerender is provided', async () => {
1289
+ const configWithoutPrerender = {
1290
+ siteId: 'site-456',
1291
+ apiKeys: ['existing-api-key-123'],
1292
+ tokowakaEnabled: false,
1293
+ enhancements: false,
1294
+ patches: {},
1295
+ };
1296
+ s3Client.send.onFirstCall().resolves({
1297
+ Body: {
1298
+ transformToString: sinon.stub().resolves(JSON.stringify(configWithoutPrerender)),
1299
+ },
1300
+ });
1301
+
1302
+ const siteId = 'site-789';
1303
+ const url = 'https://example.com';
1304
+ const prerenderConfig = { allowList: ['/*'] };
1305
+
1306
+ const result = await client.updateMetaconfig(url, siteId, { prerender: prerenderConfig });
1307
+
1308
+ expect(result).to.have.property('prerender');
1309
+ expect(result.prerender).to.deep.equal(prerenderConfig);
1310
+ });
1147
1311
  });
1148
1312
 
1149
1313
  describe('uploadConfig', () => {
@@ -68,7 +68,6 @@ describe('BaseOpportunityMapper', () => {
68
68
  const testMapper = new TestMapper(log);
69
69
  const suggestion = {
70
70
  getId: () => 'test-123',
71
- getData: () => ({}),
72
71
  getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
73
72
  };
74
73
 
@@ -94,7 +93,6 @@ describe('BaseOpportunityMapper', () => {
94
93
  const testMapper = new TestMapper(log);
95
94
  const suggestion = {
96
95
  getId: () => 'test-no-date',
97
- getData: () => ({}),
98
96
  getUpdatedAt: () => null, // Returns null
99
97
  };
100
98
 
@@ -109,54 +107,6 @@ describe('BaseOpportunityMapper', () => {
109
107
  expect(patch.prerenderRequired).to.be.true;
110
108
  });
111
109
 
112
- it('should prioritize scrapedAt from getData()', () => {
113
- class TestMapper extends BaseOpportunityMapper {
114
- getOpportunityType() { return 'test'; }
115
-
116
- requiresPrerender() { return true; }
117
-
118
- suggestionsToPatches() { return []; }
119
-
120
- canDeploy() { return { eligible: true }; }
121
- }
122
-
123
- const testMapper = new TestMapper(log);
124
- const scrapedTime = '2025-01-20T15:30:00.000Z';
125
- const suggestion = {
126
- getId: () => 'test-scraped',
127
- getData: () => ({ scrapedAt: scrapedTime }),
128
- getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
129
- };
130
-
131
- const patch = testMapper.createBasePatch(suggestion, 'opp-scraped');
132
-
133
- expect(patch.lastUpdated).to.equal(new Date(scrapedTime).getTime());
134
- });
135
-
136
- it('should use transformRules.scrapedAt when scrapedAt is not available', () => {
137
- class TestMapper extends BaseOpportunityMapper {
138
- getOpportunityType() { return 'test'; }
139
-
140
- requiresPrerender() { return true; }
141
-
142
- suggestionsToPatches() { return []; }
143
-
144
- canDeploy() { return { eligible: true }; }
145
- }
146
-
147
- const testMapper = new TestMapper(log);
148
- const transformScrapedTime = '2025-01-18T12:00:00.000Z';
149
- const suggestion = {
150
- getId: () => 'test-transform',
151
- getData: () => ({ transformRules: { scrapedAt: transformScrapedTime } }),
152
- getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
153
- };
154
-
155
- const patch = testMapper.createBasePatch(suggestion, 'opp-transform');
156
-
157
- expect(patch.lastUpdated).to.equal(new Date(transformScrapedTime).getTime());
158
- });
159
-
160
110
  it('should handle invalid date strings by using Date.now()', () => {
161
111
  class TestMapper extends BaseOpportunityMapper {
162
112
  getOpportunityType() { return 'test'; }
@@ -171,7 +121,6 @@ describe('BaseOpportunityMapper', () => {
171
121
  const testMapper = new TestMapper(log);
172
122
  const suggestion = {
173
123
  getId: () => 'test-invalid',
174
- getData: () => ({}),
175
124
  getUpdatedAt: () => 'invalid-date-string',
176
125
  };
177
126
 
@@ -183,29 +132,6 @@ describe('BaseOpportunityMapper', () => {
183
132
  expect(patch.lastUpdated).to.be.at.least(beforeTime);
184
133
  expect(patch.lastUpdated).to.be.at.most(afterTime);
185
134
  });
186
-
187
- it('should handle missing getData() gracefully', () => {
188
- class TestMapper extends BaseOpportunityMapper {
189
- getOpportunityType() { return 'test'; }
190
-
191
- requiresPrerender() { return true; }
192
-
193
- suggestionsToPatches() { return []; }
194
-
195
- canDeploy() { return { eligible: true }; }
196
- }
197
-
198
- const testMapper = new TestMapper(log);
199
- const suggestion = {
200
- getId: () => 'test-no-data',
201
- getData: () => null,
202
- getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
203
- };
204
-
205
- const patch = testMapper.createBasePatch(suggestion, 'opp-no-data');
206
-
207
- expect(patch.lastUpdated).to.equal(new Date('2025-01-15T10:00:00.000Z').getTime());
208
- });
209
135
  });
210
136
 
211
137
  describe('rollbackPatches', () => {
@@ -98,11 +98,12 @@ describe('HeadingsMapper', () => {
98
98
  const suggestion = {
99
99
  getData: () => ({
100
100
  checkType: 'heading-order-invalid',
101
- recommendedAction: { type: 'element', tagName: 'h2', children: [] },
101
+ recommendedAction: 'Adjust heading levels to maintain proper hierarchy.',
102
102
  transformRules: {
103
103
  action: 'replaceWith',
104
104
  selector: '.invalid-section',
105
105
  valueFormat: 'hast',
106
+ value: { type: 'element', tagName: 'h2', children: [] },
106
107
  },
107
108
  }),
108
109
  };
@@ -254,11 +255,12 @@ describe('HeadingsMapper', () => {
254
255
  const suggestion = {
255
256
  getData: () => ({
256
257
  checkType: 'heading-order-invalid',
257
- recommendedAction: { type: 'element', tagName: 'h2', children: [] },
258
+ recommendedAction: 'Adjust heading levels to maintain proper hierarchy.',
258
259
  transformRules: {
259
260
  action: 'replace',
260
261
  selector: '.invalid-section',
261
262
  valueFormat: 'hast',
263
+ value: { type: 'element', tagName: 'h2', children: [] },
262
264
  },
263
265
  }),
264
266
  };
@@ -275,10 +277,11 @@ describe('HeadingsMapper', () => {
275
277
  const suggestion = {
276
278
  getData: () => ({
277
279
  checkType: 'heading-order-invalid',
278
- recommendedAction: { type: 'element', tagName: 'h2', children: [] },
280
+ recommendedAction: 'Adjust heading levels to maintain proper hierarchy.',
279
281
  transformRules: {
280
282
  action: 'replaceWith',
281
283
  selector: '.invalid-section',
284
+ value: { type: 'element', tagName: 'h2', children: [] },
282
285
  },
283
286
  }),
284
287
  };
@@ -295,11 +298,12 @@ describe('HeadingsMapper', () => {
295
298
  const suggestion = {
296
299
  getData: () => ({
297
300
  checkType: 'heading-order-invalid',
298
- recommendedAction: { type: 'element', tagName: 'h2', children: [] },
301
+ recommendedAction: 'Adjust heading levels to maintain proper hierarchy.',
299
302
  transformRules: {
300
303
  action: 'replaceWith',
301
304
  selector: '.invalid-section',
302
305
  valueFormat: 'text',
306
+ value: { type: 'element', tagName: 'h2', children: [] },
303
307
  },
304
308
  }),
305
309
  };
@@ -447,11 +451,12 @@ describe('HeadingsMapper', () => {
447
451
  getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
448
452
  getData: () => ({
449
453
  checkType: 'heading-order-invalid',
450
- recommendedAction: hastValue,
454
+ recommendedAction: 'Adjust heading levels to maintain proper hierarchy.',
451
455
  transformRules: {
452
456
  action: 'replaceWith',
453
457
  selector: '.content-section',
454
458
  valueFormat: 'hast',
459
+ value: hastValue,
455
460
  },
456
461
  }),
457
462
  };
@@ -464,6 +469,7 @@ describe('HeadingsMapper', () => {
464
469
  op: 'replaceWith',
465
470
  selector: '.content-section',
466
471
  value: hastValue,
472
+ valueFormat: 'hast',
467
473
  opportunityId: 'opp-101',
468
474
  suggestionId: 'sugg-101',
469
475
  prerenderRequired: true,
@@ -477,7 +483,7 @@ describe('HeadingsMapper', () => {
477
483
  getId: () => 'sugg-102',
478
484
  getData: () => ({
479
485
  checkType: 'heading-order-invalid',
480
- recommendedAction: { type: 'element', tagName: 'h2', children: [] },
486
+ recommendedAction: 'Adjust heading levels to maintain proper hierarchy.',
481
487
  }),
482
488
  };
483
489
 
@@ -490,11 +496,12 @@ describe('HeadingsMapper', () => {
490
496
  getId: () => 'sugg-103',
491
497
  getData: () => ({
492
498
  checkType: 'heading-order-invalid',
493
- recommendedAction: { type: 'element', tagName: 'h2', children: [] },
499
+ recommendedAction: 'Adjust heading levels to maintain proper hierarchy.',
494
500
  transformRules: {
495
501
  action: 'replace',
496
502
  selector: '.content-section',
497
503
  valueFormat: 'hast',
504
+ value: { type: 'element', tagName: 'h2', children: [] },
498
505
  },
499
506
  }),
500
507
  };
@@ -503,6 +510,233 @@ describe('HeadingsMapper', () => {
503
510
  expect(patches.length).to.equal(0);
504
511
  });
505
512
 
513
+ it('should create patch for heading-order-invalid with real-world HAST structure', () => {
514
+ const hastValue = {
515
+ type: 'root',
516
+ children: [
517
+ {
518
+ type: 'element',
519
+ tagName: 'h2',
520
+ children: [
521
+ {
522
+ type: 'text',
523
+ value: 'Complete Cover Sets',
524
+ },
525
+ ],
526
+ properties: {},
527
+ },
528
+ ],
529
+ };
530
+
531
+ const suggestion = {
532
+ getId: () => 'sugg-104',
533
+ getUpdatedAt: () => '2026-01-03T06:24:06.229Z',
534
+ getData: () => ({
535
+ checkType: 'heading-order-invalid',
536
+ recommendedAction: 'Adjust heading levels to maintain proper hierarchy.',
537
+ transformRules: {
538
+ action: 'replaceWith',
539
+ selector: 'h4#complete-cover-sets',
540
+ valueFormat: 'hast',
541
+ value: hastValue,
542
+ scrapedAt: '2026-01-03T06:24:06.229Z',
543
+ currValue: 'Complete Cover Sets',
544
+ },
545
+ }),
546
+ };
547
+
548
+ const patches = mapper.suggestionsToPatches('/path', [suggestion], 'opp-104');
549
+ expect(patches.length).to.equal(1);
550
+ const patch = patches[0];
551
+
552
+ expect(patch).to.deep.include({
553
+ op: 'replaceWith',
554
+ selector: 'h4#complete-cover-sets',
555
+ value: hastValue,
556
+ valueFormat: 'hast',
557
+ opportunityId: 'opp-104',
558
+ suggestionId: 'sugg-104',
559
+ prerenderRequired: true,
560
+ });
561
+ expect(patch.lastUpdated).to.be.a('number');
562
+ });
563
+
564
+ it('should create patch for heading-order-invalid with complex selector', () => {
565
+ const hastValue = {
566
+ type: 'root',
567
+ children: [
568
+ {
569
+ type: 'element',
570
+ tagName: 'h3',
571
+ properties: { id: 'section-title' },
572
+ children: [{ type: 'text', value: 'Updated Section' }],
573
+ },
574
+ ],
575
+ };
576
+
577
+ const suggestion = {
578
+ getId: () => 'sugg-105',
579
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
580
+ getData: () => ({
581
+ checkType: 'heading-order-invalid',
582
+ recommendedAction: 'Fix heading hierarchy from h5 to h3.',
583
+ transformRules: {
584
+ action: 'replaceWith',
585
+ selector: 'body > main > section:nth-child(2) > h5',
586
+ valueFormat: 'hast',
587
+ value: hastValue,
588
+ },
589
+ }),
590
+ };
591
+
592
+ const patches = mapper.suggestionsToPatches('/path', [suggestion], 'opp-105');
593
+ expect(patches.length).to.equal(1);
594
+ const patch = patches[0];
595
+
596
+ expect(patch.selector).to.equal('body > main > section:nth-child(2) > h5');
597
+ expect(patch.value).to.deep.equal(hastValue);
598
+ expect(patch.valueFormat).to.equal('hast');
599
+ });
600
+
601
+ it('should handle multiple heading-order-invalid suggestions', () => {
602
+ const suggestions = [
603
+ {
604
+ getId: () => 'sugg-106',
605
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
606
+ getData: () => ({
607
+ checkType: 'heading-order-invalid',
608
+ recommendedAction: 'Fix heading hierarchy.',
609
+ transformRules: {
610
+ action: 'replaceWith',
611
+ selector: 'h4.title',
612
+ valueFormat: 'hast',
613
+ value: {
614
+ type: 'root',
615
+ children: [
616
+ {
617
+ type: 'element',
618
+ tagName: 'h2',
619
+ properties: { class: 'title' },
620
+ children: [{ type: 'text', value: 'Title 1' }],
621
+ },
622
+ ],
623
+ },
624
+ },
625
+ }),
626
+ },
627
+ {
628
+ getId: () => 'sugg-107',
629
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
630
+ getData: () => ({
631
+ checkType: 'heading-order-invalid',
632
+ recommendedAction: 'Fix another heading.',
633
+ transformRules: {
634
+ action: 'replaceWith',
635
+ selector: 'h5.subtitle',
636
+ valueFormat: 'hast',
637
+ value: {
638
+ type: 'root',
639
+ children: [
640
+ {
641
+ type: 'element',
642
+ tagName: 'h3',
643
+ properties: { class: 'subtitle' },
644
+ children: [{ type: 'text', value: 'Subtitle' }],
645
+ },
646
+ ],
647
+ },
648
+ },
649
+ }),
650
+ },
651
+ ];
652
+
653
+ const patches = mapper.suggestionsToPatches('/path', suggestions, 'opp-106');
654
+ expect(patches.length).to.equal(2);
655
+ expect(patches[0].selector).to.equal('h4.title');
656
+ expect(patches[1].selector).to.equal('h5.subtitle');
657
+ expect(patches[0].valueFormat).to.equal('hast');
658
+ expect(patches[1].valueFormat).to.equal('hast');
659
+ });
660
+
661
+ it('should not use text valueFormat for heading-order-invalid', () => {
662
+ const hastValue = {
663
+ type: 'root',
664
+ children: [
665
+ { type: 'element', tagName: 'h2', children: [{ type: 'text', value: 'Heading' }] },
666
+ ],
667
+ };
668
+
669
+ const suggestion = {
670
+ getId: () => 'sugg-108',
671
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
672
+ getData: () => ({
673
+ checkType: 'heading-order-invalid',
674
+ recommendedAction: 'Fix heading hierarchy.',
675
+ transformRules: {
676
+ action: 'replaceWith',
677
+ selector: 'h4',
678
+ valueFormat: 'hast',
679
+ value: hastValue,
680
+ },
681
+ }),
682
+ };
683
+
684
+ const patches = mapper.suggestionsToPatches('/path', [suggestion], 'opp-108');
685
+ expect(patches.length).to.equal(1);
686
+ const patch = patches[0];
687
+
688
+ // Verify it uses HAST format, not text
689
+ expect(patch.valueFormat).to.equal('hast');
690
+ expect(patch.value).to.be.an('object');
691
+ expect(patch.value).to.deep.equal(hastValue);
692
+ });
693
+
694
+ it('should include currValue when currentValue is not null for heading-empty', () => {
695
+ const suggestion = {
696
+ getId: () => 'sugg-109',
697
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
698
+ getData: () => ({
699
+ checkType: 'heading-empty',
700
+ recommendedAction: 'New Heading Text',
701
+ currentValue: 'Old Heading',
702
+ transformRules: {
703
+ action: 'replace',
704
+ selector: 'h2.empty',
705
+ },
706
+ }),
707
+ };
708
+
709
+ const patches = mapper.suggestionsToPatches('/path', [suggestion], 'opp-109');
710
+ expect(patches.length).to.equal(1);
711
+ const patch = patches[0];
712
+
713
+ expect(patch.currValue).to.equal('Old Heading');
714
+ expect(patch.value).to.equal('New Heading Text');
715
+ });
716
+
717
+ it('should not include currValue when currentValue is null', () => {
718
+ const suggestion = {
719
+ getId: () => 'sugg-110',
720
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
721
+ getData: () => ({
722
+ checkType: 'heading-empty',
723
+ recommendedAction: 'New Heading Text',
724
+ currentValue: null,
725
+ transformRules: {
726
+ action: 'replace',
727
+ selector: 'h2.empty',
728
+ },
729
+ }),
730
+ };
731
+
732
+ const patches = mapper.suggestionsToPatches('/path', [suggestion], 'opp-110');
733
+ expect(patches.length).to.equal(1);
734
+ const patch = patches[0];
735
+
736
+ expect(patch.currValue).to.be.undefined;
737
+ expect(patch.value).to.equal('New Heading Text');
738
+ });
739
+
506
740
  it('should return empty array for heading-missing-h1 without transformRules', () => {
507
741
  const suggestion = {
508
742
  getId: () => 'sugg-999',
@@ -261,7 +261,6 @@ describe('ReadabilityMapper', () => {
261
261
  getData: () => ({
262
262
  textPreview: 'Lorem ipsum...',
263
263
  url: 'https://www.website.com',
264
- scrapedAt: '2025-09-20T06:21:12.584Z',
265
264
  transformRules: {
266
265
  value: 'Tech enthusiasts keep up with the latest tech news...',
267
266
  op: 'replace',
@@ -290,14 +289,13 @@ describe('ReadabilityMapper', () => {
290
289
  expect(patch.lastUpdated).to.be.a('number');
291
290
  });
292
291
 
293
- it('should create patch with scrapedAt timestamp', () => {
292
+ it('should create patch with updatedAt timestamp', () => {
294
293
  const suggestion = {
295
294
  getId: () => 'sugg-456',
296
- getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
295
+ getUpdatedAt: () => '2025-09-20T06:21:12.584Z',
297
296
  getData: () => ({
298
297
  textPreview: 'Original text...',
299
298
  url: 'https://www.example.com',
300
- scrapedAt: '2025-09-20T06:21:12.584Z',
301
299
  transformRules: {
302
300
  value: 'Improved readability text',
303
301
  op: 'replace',
@@ -340,7 +340,6 @@ describe('TocMapper', () => {
340
340
  selector: 'h1#main-heading',
341
341
  valueFormat: 'hast',
342
342
  value: tocValue,
343
- scrapedAt: '2025-12-06T06:27:04.663Z',
344
343
  },
345
344
  }),
346
345
  };