@adobe/spacecat-shared-tokowaka-client 1.2.3 → 1.3.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 CHANGED
@@ -1,3 +1,17 @@
1
+ # [@adobe/spacecat-shared-tokowaka-client-v1.3.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.2.4...@adobe/spacecat-shared-tokowaka-client-v1.3.0) (2025-12-04)
2
+
3
+
4
+ ### Features
5
+
6
+ * Tokowaka generic oppty mapper ([#1204](https://github.com/adobe/spacecat-shared/issues/1204)) ([710a36c](https://github.com/adobe/spacecat-shared/commit/710a36cd8b4e6f91a702da252c5a8739444a4e46))
7
+
8
+ # [@adobe/spacecat-shared-tokowaka-client-v1.2.4](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.2.3...@adobe/spacecat-shared-tokowaka-client-v1.2.4) (2025-11-29)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * url param in tokowaka readability mapper ([#1192](https://github.com/adobe/spacecat-shared/issues/1192)) ([566b9f1](https://github.com/adobe/spacecat-shared/commit/566b9f1d9ca03a9ffe6bbf59a89d6ccba8b278c0))
14
+
1
15
  # [@adobe/spacecat-shared-tokowaka-client-v1.2.3](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.2.2...@adobe/spacecat-shared-tokowaka-client-v1.2.3) (2025-11-28)
2
16
 
3
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/spacecat-shared-tokowaka-client",
3
- "version": "1.2.3",
3
+ "version": "1.3.0",
4
4
  "description": "Tokowaka Client for SpaceCat - Edge optimization config management",
5
5
  "type": "module",
6
6
  "engines": {
@@ -0,0 +1,117 @@
1
+ /*
2
+ * Copyright 2025 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ import { hasText } from '@adobe/spacecat-shared-utils';
14
+ import { TARGET_USER_AGENTS_CATEGORIES } from '../constants.js';
15
+ import BaseOpportunityMapper from './base-mapper.js';
16
+
17
+ /**
18
+ * Generic mapper for code change opportunities
19
+ * Handles conversion of generic code change suggestions to Tokowaka patches
20
+ */
21
+ export default class GenericMapper extends BaseOpportunityMapper {
22
+ constructor(log) {
23
+ super(log);
24
+ this.opportunityType = 'generic-autofix-edge';
25
+ this.prerenderRequired = true;
26
+ }
27
+
28
+ getOpportunityType() {
29
+ return this.opportunityType;
30
+ }
31
+
32
+ requiresPrerender() {
33
+ return this.prerenderRequired;
34
+ }
35
+
36
+ /**
37
+ * Converts suggestions to Tokowaka patches
38
+ * @param {string} urlPath - URL path for the suggestions
39
+ * @param {Array} suggestions - Array of suggestion entities for the same URL
40
+ * @param {string} opportunityId - Opportunity ID
41
+ * @returns {Array} - Array of Tokowaka patch objects
42
+ */
43
+ suggestionsToPatches(urlPath, suggestions, opportunityId) {
44
+ const patches = [];
45
+
46
+ suggestions.forEach((suggestion) => {
47
+ const eligibility = this.canDeploy(suggestion);
48
+ if (!eligibility.eligible) {
49
+ this.log.warn(`Generic suggestion ${suggestion.getId()} cannot be deployed: ${eligibility.reason}`);
50
+ return;
51
+ }
52
+
53
+ const data = suggestion.getData();
54
+ const { transformRules } = data;
55
+
56
+ const patch = {
57
+ ...this.createBasePatch(suggestion, opportunityId),
58
+ op: transformRules.action,
59
+ selector: transformRules.selector,
60
+ value: data.patchValue,
61
+ valueFormat: data.format || 'text',
62
+ target: TARGET_USER_AGENTS_CATEGORIES.AI_BOTS,
63
+ };
64
+
65
+ if (data.tag) {
66
+ patch.tag = data.tag;
67
+ }
68
+
69
+ patches.push(patch);
70
+ });
71
+
72
+ return patches;
73
+ }
74
+
75
+ /**
76
+ * Checks if a generic suggestion can be deployed
77
+ * @param {Object} suggestion - Suggestion object
78
+ * @returns {Object} { eligible: boolean, reason?: string }
79
+ */
80
+ // eslint-disable-next-line class-methods-use-this
81
+ canDeploy(suggestion) {
82
+ const data = suggestion.getData();
83
+
84
+ // Validate transformRules exists
85
+ if (!data?.transformRules) {
86
+ return { eligible: false, reason: 'transformRules is required' };
87
+ }
88
+
89
+ // Validate required fields
90
+ if (!hasText(data.transformRules.selector)) {
91
+ return { eligible: false, reason: 'transformRules.selector is required' };
92
+ }
93
+
94
+ if (!hasText(data?.patchValue)) {
95
+ return { eligible: false, reason: 'patchValue is required' };
96
+ }
97
+
98
+ if (!hasText(data.transformRules.action)) {
99
+ return { eligible: false, reason: 'transformRules.action is required' };
100
+ }
101
+
102
+ // Validate action value
103
+ const validOperations = ['insertBefore', 'insertAfter', 'replace'];
104
+ if (!validOperations.includes(data.transformRules.action)) {
105
+ return {
106
+ eligible: false,
107
+ reason: `transformRules.action must be one of: ${validOperations.join(', ')}. Got: ${data.transformRules.action}`,
108
+ };
109
+ }
110
+
111
+ if (!hasText(data?.url)) {
112
+ return { eligible: false, reason: 'url is required' };
113
+ }
114
+
115
+ return { eligible: true };
116
+ }
117
+ }
@@ -14,6 +14,7 @@ import HeadingsMapper from './headings-mapper.js';
14
14
  import ContentSummarizationMapper from './content-summarization-mapper.js';
15
15
  import FaqMapper from './faq-mapper.js';
16
16
  import ReadabilityMapper from './readability-mapper.js';
17
+ import GenericMapper from './generic-mapper.js';
17
18
 
18
19
  /**
19
20
  * Registry for opportunity mappers
@@ -36,6 +37,7 @@ export default class MapperRegistry {
36
37
  ContentSummarizationMapper,
37
38
  FaqMapper,
38
39
  ReadabilityMapper,
40
+ GenericMapper,
39
41
  // more mappers here
40
42
  ];
41
43
 
@@ -89,8 +89,8 @@ export default class ReadabilityMapper extends BaseOpportunityMapper {
89
89
 
90
90
  const { transformRules } = data;
91
91
 
92
- if (!isValidUrl(data.pageUrl)) {
93
- return { eligible: false, reason: `pageUrl ${data.pageUrl} is not a valid URL` };
92
+ if (!isValidUrl(data.url)) {
93
+ return { eligible: false, reason: `url ${data.url} is not a valid URL` };
94
94
  }
95
95
 
96
96
  if (!hasText(transformRules.selector)) {
@@ -0,0 +1,665 @@
1
+ /*
2
+ * Copyright 2025 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ /* eslint-env mocha */
14
+
15
+ import { expect } from 'chai';
16
+ import GenericMapper from '../../src/mappers/generic-mapper.js';
17
+
18
+ describe('GenericMapper', () => {
19
+ let mapper;
20
+ let log;
21
+
22
+ beforeEach(() => {
23
+ log = {
24
+ debug: () => {},
25
+ info: () => {},
26
+ warn: () => {},
27
+ error: () => {},
28
+ };
29
+ mapper = new GenericMapper(log);
30
+ });
31
+
32
+ describe('getOpportunityType', () => {
33
+ it('should return generic', () => {
34
+ expect(mapper.getOpportunityType()).to.equal('generic-autofix-edge');
35
+ });
36
+ });
37
+
38
+ describe('requiresPrerender', () => {
39
+ it('should return true', () => {
40
+ expect(mapper.requiresPrerender()).to.be.true;
41
+ });
42
+ });
43
+
44
+ describe('canDeploy', () => {
45
+ it('should return eligible for valid suggestion with all required fields', () => {
46
+ const suggestion = {
47
+ getData: () => ({
48
+ transformRules: {
49
+ action: 'insertAfter',
50
+ selector: '#create-with-multiple-top-ai-models-all-in-one-place',
51
+ },
52
+ patchValue: 'Blah Blah some text',
53
+ url: 'https://www.adobe.com/products/firefly.html',
54
+ }),
55
+ };
56
+
57
+ const result = mapper.canDeploy(suggestion);
58
+
59
+ expect(result).to.deep.equal({ eligible: true });
60
+ });
61
+
62
+ it('should return eligible for insertBefore operation', () => {
63
+ const suggestion = {
64
+ getData: () => ({
65
+ transformRules: {
66
+ action: 'insertBefore',
67
+ selector: 'h1',
68
+ },
69
+ patchValue: 'New content',
70
+ url: 'https://example.com/page',
71
+ }),
72
+ };
73
+
74
+ const result = mapper.canDeploy(suggestion);
75
+
76
+ expect(result).to.deep.equal({ eligible: true });
77
+ });
78
+
79
+ it('should return eligible for replace operation', () => {
80
+ const suggestion = {
81
+ getData: () => ({
82
+ transformRules: {
83
+ action: 'replace',
84
+ selector: '.content',
85
+ },
86
+ patchValue: 'Replaced content',
87
+ url: 'https://example.com/page',
88
+ }),
89
+ };
90
+
91
+ const result = mapper.canDeploy(suggestion);
92
+
93
+ expect(result).to.deep.equal({ eligible: true });
94
+ });
95
+
96
+ it('should return ineligible when transformRules is missing', () => {
97
+ const suggestion = {
98
+ getData: () => ({
99
+ patchValue: 'Some text',
100
+ url: 'https://example.com/page',
101
+ }),
102
+ };
103
+
104
+ const result = mapper.canDeploy(suggestion);
105
+
106
+ expect(result).to.deep.equal({
107
+ eligible: false,
108
+ reason: 'transformRules is required',
109
+ });
110
+ });
111
+
112
+ it('should return ineligible when selector is missing', () => {
113
+ const suggestion = {
114
+ getData: () => ({
115
+ transformRules: {
116
+ action: 'insertAfter',
117
+ },
118
+ patchValue: 'Some text',
119
+ url: 'https://example.com/page',
120
+ }),
121
+ };
122
+
123
+ const result = mapper.canDeploy(suggestion);
124
+
125
+ expect(result).to.deep.equal({
126
+ eligible: false,
127
+ reason: 'transformRules.selector is required',
128
+ });
129
+ });
130
+
131
+ it('should return ineligible when selector is empty string', () => {
132
+ const suggestion = {
133
+ getData: () => ({
134
+ transformRules: {
135
+ action: 'insertAfter',
136
+ selector: '',
137
+ },
138
+ patchValue: 'Some text',
139
+ url: 'https://example.com/page',
140
+ }),
141
+ };
142
+
143
+ const result = mapper.canDeploy(suggestion);
144
+
145
+ expect(result).to.deep.equal({
146
+ eligible: false,
147
+ reason: 'transformRules.selector is required',
148
+ });
149
+ });
150
+
151
+ it('should return ineligible when patchValue is missing', () => {
152
+ const suggestion = {
153
+ getData: () => ({
154
+ transformRules: {
155
+ action: 'insertAfter',
156
+ selector: '#selector',
157
+ },
158
+ url: 'https://example.com/page',
159
+ }),
160
+ };
161
+
162
+ const result = mapper.canDeploy(suggestion);
163
+
164
+ expect(result).to.deep.equal({
165
+ eligible: false,
166
+ reason: 'patchValue is required',
167
+ });
168
+ });
169
+
170
+ it('should return ineligible when patchValue is empty string', () => {
171
+ const suggestion = {
172
+ getData: () => ({
173
+ transformRules: {
174
+ action: 'insertAfter',
175
+ selector: '#selector',
176
+ },
177
+ patchValue: '',
178
+ url: 'https://example.com/page',
179
+ }),
180
+ };
181
+
182
+ const result = mapper.canDeploy(suggestion);
183
+
184
+ expect(result).to.deep.equal({
185
+ eligible: false,
186
+ reason: 'patchValue is required',
187
+ });
188
+ });
189
+
190
+ it('should return ineligible when action is missing', () => {
191
+ const suggestion = {
192
+ getData: () => ({
193
+ transformRules: {
194
+ selector: '#selector',
195
+ },
196
+ patchValue: 'Some text',
197
+ url: 'https://example.com/page',
198
+ }),
199
+ };
200
+
201
+ const result = mapper.canDeploy(suggestion);
202
+
203
+ expect(result).to.deep.equal({
204
+ eligible: false,
205
+ reason: 'transformRules.action is required',
206
+ });
207
+ });
208
+
209
+ it('should return ineligible when action is empty string', () => {
210
+ const suggestion = {
211
+ getData: () => ({
212
+ transformRules: {
213
+ action: '',
214
+ selector: '#selector',
215
+ },
216
+ patchValue: 'Some text',
217
+ url: 'https://example.com/page',
218
+ }),
219
+ };
220
+
221
+ const result = mapper.canDeploy(suggestion);
222
+
223
+ expect(result).to.deep.equal({
224
+ eligible: false,
225
+ reason: 'transformRules.action is required',
226
+ });
227
+ });
228
+
229
+ it('should return ineligible when action is invalid', () => {
230
+ const suggestion = {
231
+ getData: () => ({
232
+ transformRules: {
233
+ action: 'invalidOperation',
234
+ selector: '#selector',
235
+ },
236
+ patchValue: 'Some text',
237
+ url: 'https://example.com/page',
238
+ }),
239
+ };
240
+
241
+ const result = mapper.canDeploy(suggestion);
242
+
243
+ expect(result).to.deep.equal({
244
+ eligible: false,
245
+ reason: 'transformRules.action must be one of: insertBefore, insertAfter, replace. Got: invalidOperation',
246
+ });
247
+ });
248
+
249
+ it('should return ineligible when url is missing', () => {
250
+ const suggestion = {
251
+ getData: () => ({
252
+ transformRules: {
253
+ action: 'insertAfter',
254
+ selector: '#selector',
255
+ },
256
+ patchValue: 'Some text',
257
+ }),
258
+ };
259
+
260
+ const result = mapper.canDeploy(suggestion);
261
+
262
+ expect(result).to.deep.equal({
263
+ eligible: false,
264
+ reason: 'url is required',
265
+ });
266
+ });
267
+
268
+ it('should return ineligible when url is empty string', () => {
269
+ const suggestion = {
270
+ getData: () => ({
271
+ transformRules: {
272
+ action: 'insertAfter',
273
+ selector: '#selector',
274
+ },
275
+ patchValue: 'Some text',
276
+ url: '',
277
+ }),
278
+ };
279
+
280
+ const result = mapper.canDeploy(suggestion);
281
+
282
+ expect(result).to.deep.equal({
283
+ eligible: false,
284
+ reason: 'url is required',
285
+ });
286
+ });
287
+
288
+ it('should return ineligible when data is null', () => {
289
+ const suggestion = {
290
+ getData: () => null,
291
+ };
292
+
293
+ const result = mapper.canDeploy(suggestion);
294
+
295
+ expect(result).to.deep.equal({
296
+ eligible: false,
297
+ reason: 'transformRules is required',
298
+ });
299
+ });
300
+
301
+ it('should return ineligible when data is undefined', () => {
302
+ const suggestion = {
303
+ getData: () => undefined,
304
+ };
305
+
306
+ const result = mapper.canDeploy(suggestion);
307
+
308
+ expect(result).to.deep.equal({
309
+ eligible: false,
310
+ reason: 'transformRules is required',
311
+ });
312
+ });
313
+ });
314
+
315
+ describe('suggestionsToPatches', () => {
316
+ it('should create patch for valid suggestion with insertAfter', () => {
317
+ const suggestion = {
318
+ getId: () => 'ee8fc5e8-29c1-4894-9391-efc10b8a5f5c',
319
+ getUpdatedAt: () => '2025-11-27T16:22:14.258Z',
320
+ getData: () => ({
321
+ transformRules: {
322
+ action: 'insertAfter',
323
+ selector: '#create-with-multiple-top-ai-models-all-in-one-place',
324
+ },
325
+ patchValue: 'Blah Blah some text',
326
+ url: 'https://www.adobe.com/products/firefly.html',
327
+ contentBefore: '**Create with multiple top AI models, all in one place.**',
328
+ rationale: 'This makes LLMs read more text about blah blah.',
329
+ }),
330
+ };
331
+
332
+ const patches = mapper.suggestionsToPatches(
333
+ '/products/firefly.html',
334
+ [suggestion],
335
+ '7a663e47-e132-4bba-954a-26419e0541b8',
336
+ );
337
+
338
+ expect(patches.length).to.equal(1);
339
+ const patch = patches[0];
340
+
341
+ expect(patch).to.deep.include({
342
+ op: 'insertAfter',
343
+ selector: '#create-with-multiple-top-ai-models-all-in-one-place',
344
+ value: 'Blah Blah some text',
345
+ valueFormat: 'text',
346
+ opportunityId: '7a663e47-e132-4bba-954a-26419e0541b8',
347
+ suggestionId: 'ee8fc5e8-29c1-4894-9391-efc10b8a5f5c',
348
+ prerenderRequired: true,
349
+ });
350
+ expect(patch.lastUpdated).to.be.a('number');
351
+ expect(patch.target).to.equal('ai-bots');
352
+ });
353
+
354
+ it('should create patch for insertBefore operation', () => {
355
+ const suggestion = {
356
+ getId: () => 'sugg-123',
357
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
358
+ getData: () => ({
359
+ transformRules: {
360
+ action: 'insertBefore',
361
+ selector: 'h1',
362
+ },
363
+ patchValue: 'Important notice',
364
+ url: 'https://example.com/page',
365
+ }),
366
+ };
367
+
368
+ const patches = mapper.suggestionsToPatches('/page', [suggestion], 'opp-123');
369
+
370
+ expect(patches.length).to.equal(1);
371
+ const patch = patches[0];
372
+
373
+ expect(patch).to.deep.include({
374
+ op: 'insertBefore',
375
+ selector: 'h1',
376
+ value: 'Important notice',
377
+ valueFormat: 'text',
378
+ opportunityId: 'opp-123',
379
+ suggestionId: 'sugg-123',
380
+ prerenderRequired: true,
381
+ });
382
+ expect(patch.lastUpdated).to.be.a('number');
383
+ });
384
+
385
+ it('should create patch for replace operation', () => {
386
+ const suggestion = {
387
+ getId: () => 'sugg-456',
388
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
389
+ getData: () => ({
390
+ transformRules: {
391
+ action: 'replace',
392
+ selector: '.content',
393
+ },
394
+ patchValue: 'Replaced content text',
395
+ url: 'https://example.com/page2',
396
+ }),
397
+ };
398
+
399
+ const patches = mapper.suggestionsToPatches('/page2', [suggestion], 'opp-456');
400
+
401
+ expect(patches.length).to.equal(1);
402
+ const patch = patches[0];
403
+
404
+ expect(patch).to.deep.include({
405
+ op: 'replace',
406
+ selector: '.content',
407
+ value: 'Replaced content text',
408
+ valueFormat: 'text',
409
+ opportunityId: 'opp-456',
410
+ suggestionId: 'sugg-456',
411
+ prerenderRequired: true,
412
+ });
413
+ expect(patch.lastUpdated).to.be.a('number');
414
+ });
415
+
416
+ it('should handle multiple suggestions', () => {
417
+ const suggestions = [
418
+ {
419
+ getId: () => 'sugg-1',
420
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
421
+ getData: () => ({
422
+ transformRules: {
423
+ action: 'insertAfter',
424
+ selector: '#selector1',
425
+ },
426
+ patchValue: 'Text 1',
427
+ url: 'https://example.com/page',
428
+ }),
429
+ },
430
+ {
431
+ getId: () => 'sugg-2',
432
+ getUpdatedAt: () => '2025-01-15T11:00:00.000Z',
433
+ getData: () => ({
434
+ transformRules: {
435
+ action: 'insertBefore',
436
+ selector: '#selector2',
437
+ },
438
+ patchValue: 'Text 2',
439
+ url: 'https://example.com/page',
440
+ }),
441
+ },
442
+ ];
443
+
444
+ const patches = mapper.suggestionsToPatches('/page', suggestions, 'opp-123');
445
+
446
+ expect(patches.length).to.equal(2);
447
+ expect(patches[0].suggestionId).to.equal('sugg-1');
448
+ expect(patches[0].value).to.equal('Text 1');
449
+ expect(patches[1].suggestionId).to.equal('sugg-2');
450
+ expect(patches[1].value).to.equal('Text 2');
451
+ });
452
+
453
+ it('should return empty array for invalid suggestion', () => {
454
+ const suggestion = {
455
+ getId: () => 'sugg-invalid',
456
+ getData: () => ({
457
+ transformRules: {
458
+ action: 'insertAfter',
459
+ selector: '#selector',
460
+ },
461
+ // Missing patchValue
462
+ url: 'https://example.com/page',
463
+ }),
464
+ };
465
+
466
+ const patches = mapper.suggestionsToPatches('/page', [suggestion], 'opp-invalid');
467
+
468
+ expect(patches.length).to.equal(0);
469
+ });
470
+
471
+ it('should skip invalid suggestions but process valid ones', () => {
472
+ const suggestions = [
473
+ {
474
+ getId: () => 'sugg-valid',
475
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
476
+ getData: () => ({
477
+ transformRules: {
478
+ action: 'insertAfter',
479
+ selector: '#valid',
480
+ },
481
+ patchValue: 'Valid text',
482
+ url: 'https://example.com/page',
483
+ }),
484
+ },
485
+ {
486
+ getId: () => 'sugg-invalid',
487
+ getData: () => ({
488
+ transformRules: {
489
+ action: 'insertAfter',
490
+ selector: '#invalid',
491
+ },
492
+ // Missing patchValue
493
+ url: 'https://example.com/page',
494
+ }),
495
+ },
496
+ ];
497
+
498
+ const patches = mapper.suggestionsToPatches('/page', suggestions, 'opp-123');
499
+
500
+ expect(patches.length).to.equal(1);
501
+ expect(patches[0].suggestionId).to.equal('sugg-valid');
502
+ });
503
+
504
+ it('should log warning for invalid suggestion', () => {
505
+ let warnMessage = '';
506
+ const warnLog = {
507
+ debug: () => {},
508
+ info: () => {},
509
+ warn: (msg) => { warnMessage = msg; },
510
+ error: () => {},
511
+ };
512
+ const warnMapper = new GenericMapper(warnLog);
513
+
514
+ const suggestion = {
515
+ getId: () => 'sugg-warn',
516
+ getData: () => ({
517
+ transformRules: {
518
+ action: 'insertAfter',
519
+ selector: '#selector',
520
+ },
521
+ // Missing patchValue
522
+ url: 'https://example.com/page',
523
+ }),
524
+ };
525
+
526
+ const patches = warnMapper.suggestionsToPatches('/page', [suggestion], 'opp-warn');
527
+
528
+ expect(patches.length).to.equal(0);
529
+ expect(warnMessage).to.include('Generic suggestion sugg-warn cannot be deployed');
530
+ expect(warnMessage).to.include('patchValue is required');
531
+ });
532
+
533
+ it('should handle complex CSS selectors', () => {
534
+ const suggestion = {
535
+ getId: () => 'sugg-complex',
536
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
537
+ getData: () => ({
538
+ transformRules: {
539
+ action: 'insertAfter',
540
+ selector: '#text-85a9876220 > h2:nth-of-type(1)',
541
+ },
542
+ patchValue: 'Complex selector content',
543
+ url: 'https://example.com/page',
544
+ }),
545
+ };
546
+
547
+ const patches = mapper.suggestionsToPatches('/page', [suggestion], 'opp-complex');
548
+
549
+ expect(patches.length).to.equal(1);
550
+ expect(patches[0].selector).to.equal('#text-85a9876220 > h2:nth-of-type(1)');
551
+ });
552
+
553
+ it('should handle multiline patchValue', () => {
554
+ const suggestion = {
555
+ getId: () => 'sugg-multiline',
556
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
557
+ getData: () => ({
558
+ transformRules: {
559
+ action: 'replace',
560
+ selector: '.content',
561
+ },
562
+ patchValue: 'Line 1\nLine 2\nLine 3',
563
+ url: 'https://example.com/page',
564
+ }),
565
+ };
566
+
567
+ const patches = mapper.suggestionsToPatches('/page', [suggestion], 'opp-multiline');
568
+
569
+ expect(patches.length).to.equal(1);
570
+ expect(patches[0].value).to.equal('Line 1\nLine 2\nLine 3');
571
+ });
572
+
573
+ it('should include tag when provided', () => {
574
+ const suggestion = {
575
+ getId: () => 'sugg-with-tag',
576
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
577
+ getData: () => ({
578
+ transformRules: {
579
+ action: 'insertAfter',
580
+ selector: '#selector',
581
+ },
582
+ patchValue: 'Content with tag',
583
+ format: 'hast',
584
+ tag: 'div',
585
+ url: 'https://example.com/page',
586
+ }),
587
+ };
588
+
589
+ const patches = mapper.suggestionsToPatches('/page', [suggestion], 'opp-tag');
590
+
591
+ expect(patches.length).to.equal(1);
592
+ const patch = patches[0];
593
+
594
+ expect(patch).to.deep.include({
595
+ op: 'insertAfter',
596
+ selector: '#selector',
597
+ value: 'Content with tag',
598
+ valueFormat: 'hast',
599
+ tag: 'div',
600
+ opportunityId: 'opp-tag',
601
+ suggestionId: 'sugg-with-tag',
602
+ prerenderRequired: true,
603
+ });
604
+ expect(patch.lastUpdated).to.be.a('number');
605
+ });
606
+
607
+ it('should not include tag when not provided', () => {
608
+ const suggestion = {
609
+ getId: () => 'sugg-no-tag',
610
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
611
+ getData: () => ({
612
+ transformRules: {
613
+ action: 'insertAfter',
614
+ selector: '#selector',
615
+ },
616
+ patchValue: 'Content without tag',
617
+ url: 'https://example.com/page',
618
+ }),
619
+ };
620
+
621
+ const patches = mapper.suggestionsToPatches('/page', [suggestion], 'opp-no-tag');
622
+
623
+ expect(patches.length).to.equal(1);
624
+ const patch = patches[0];
625
+
626
+ expect(patch.tag).to.be.undefined;
627
+ expect(patch.valueFormat).to.equal('text');
628
+ });
629
+
630
+ it('should not include UI-only fields in patch', () => {
631
+ const suggestion = {
632
+ getId: () => 'sugg-ui',
633
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
634
+ getData: () => ({
635
+ transformRules: {
636
+ action: 'insertAfter',
637
+ selector: '#selector',
638
+ },
639
+ patchValue: 'Text content',
640
+ url: 'https://example.com/page',
641
+ contentBefore: 'Original content',
642
+ expectedContentAfter: 'Expected result',
643
+ rationale: 'This improves SEO',
644
+ aggregationKey: 'some-key',
645
+ }),
646
+ };
647
+
648
+ const patches = mapper.suggestionsToPatches('/page', [suggestion], 'opp-ui');
649
+
650
+ expect(patches.length).to.equal(1);
651
+ const patch = patches[0];
652
+
653
+ // Should not include UI-only fields
654
+ expect(patch.contentBefore).to.be.undefined;
655
+ expect(patch.expectedContentAfter).to.be.undefined;
656
+ expect(patch.rationale).to.be.undefined;
657
+ expect(patch.aggregationKey).to.be.undefined;
658
+
659
+ // Should include only operational fields
660
+ expect(patch.op).to.equal('insertAfter');
661
+ expect(patch.selector).to.equal('#selector');
662
+ expect(patch.value).to.equal('Text content');
663
+ });
664
+ });
665
+ });
@@ -46,7 +46,7 @@ describe('ReadabilityMapper', () => {
46
46
  const suggestion = {
47
47
  getData: () => ({
48
48
  textPreview: 'Lorem ipsum...',
49
- pageUrl: 'https://www.website.com',
49
+ url: 'https://www.website.com',
50
50
  transformRules: {
51
51
  value: 'Tech enthusiasts keep up with the latest tech news...',
52
52
  op: 'replace',
@@ -66,7 +66,7 @@ describe('ReadabilityMapper', () => {
66
66
  const suggestion = {
67
67
  getData: () => ({
68
68
  textPreview: 'Lorem ipsum...',
69
- pageUrl: 'https://www.website.com',
69
+ url: 'https://www.website.com',
70
70
  transformRules: {
71
71
  value: 'Improved readability text...',
72
72
  op: 'replace',
@@ -84,7 +84,7 @@ describe('ReadabilityMapper', () => {
84
84
  const suggestion = {
85
85
  getData: () => ({
86
86
  textPreview: 'Lorem ipsum...',
87
- pageUrl: 'https://www.website.com',
87
+ url: 'https://www.website.com',
88
88
  }),
89
89
  };
90
90
 
@@ -113,7 +113,7 @@ describe('ReadabilityMapper', () => {
113
113
  const suggestion = {
114
114
  getData: () => ({
115
115
  textPreview: 'Text...',
116
- pageUrl: 'https://www.example.com',
116
+ url: 'https://www.example.com',
117
117
  transformRules: {
118
118
  value: 'New text',
119
119
  op: 'replace',
@@ -133,7 +133,7 @@ describe('ReadabilityMapper', () => {
133
133
  const suggestion = {
134
134
  getData: () => ({
135
135
  textPreview: 'Text...',
136
- pageUrl: 'https://www.example.com',
136
+ url: 'https://www.example.com',
137
137
  transformRules: {
138
138
  value: 'New text',
139
139
  selector: '#content',
@@ -153,7 +153,7 @@ describe('ReadabilityMapper', () => {
153
153
  const suggestion = {
154
154
  getData: () => ({
155
155
  textPreview: 'Text...',
156
- pageUrl: 'https://www.example.com',
156
+ url: 'https://www.example.com',
157
157
  transformRules: {
158
158
  value: 'New text',
159
159
  op: 'insertAfter',
@@ -174,7 +174,7 @@ describe('ReadabilityMapper', () => {
174
174
  const suggestion = {
175
175
  getData: () => ({
176
176
  textPreview: 'Text...',
177
- pageUrl: 'https://www.example.com',
177
+ url: 'https://www.example.com',
178
178
  transformRules: {
179
179
  op: 'replace',
180
180
  selector: '#content',
@@ -194,7 +194,7 @@ describe('ReadabilityMapper', () => {
194
194
  const suggestion = {
195
195
  getData: () => ({
196
196
  textPreview: 'Text...',
197
- pageUrl: 'https://www.example.com',
197
+ url: 'https://www.example.com',
198
198
  transformRules: {
199
199
  value: 'New text',
200
200
  op: 'replace',
@@ -211,11 +211,11 @@ describe('ReadabilityMapper', () => {
211
211
  });
212
212
  });
213
213
 
214
- it('should return ineligible when pageUrl is invalid', () => {
214
+ it('should return ineligible when url is invalid', () => {
215
215
  const suggestion = {
216
216
  getData: () => ({
217
217
  textPreview: 'Text...',
218
- pageUrl: 'not-a-valid-url',
218
+ url: 'not-a-valid-url',
219
219
  transformRules: {
220
220
  value: 'New text',
221
221
  op: 'replace',
@@ -228,11 +228,11 @@ describe('ReadabilityMapper', () => {
228
228
 
229
229
  expect(result).to.deep.equal({
230
230
  eligible: false,
231
- reason: 'pageUrl not-a-valid-url is not a valid URL',
231
+ reason: 'url not-a-valid-url is not a valid URL',
232
232
  });
233
233
  });
234
234
 
235
- it('should return ineligible when pageUrl is missing', () => {
235
+ it('should return ineligible when url is missing', () => {
236
236
  const suggestion = {
237
237
  getData: () => ({
238
238
  textPreview: 'Text...',
@@ -248,7 +248,7 @@ describe('ReadabilityMapper', () => {
248
248
 
249
249
  expect(result).to.deep.equal({
250
250
  eligible: false,
251
- reason: 'pageUrl undefined is not a valid URL',
251
+ reason: 'url undefined is not a valid URL',
252
252
  });
253
253
  });
254
254
  });
@@ -260,7 +260,7 @@ describe('ReadabilityMapper', () => {
260
260
  getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
261
261
  getData: () => ({
262
262
  textPreview: 'Lorem ipsum...',
263
- pageUrl: 'https://www.website.com',
263
+ url: 'https://www.website.com',
264
264
  scrapedAt: '2025-09-20T06:21:12.584Z',
265
265
  transformRules: {
266
266
  value: 'Tech enthusiasts keep up with the latest tech news...',
@@ -296,7 +296,7 @@ describe('ReadabilityMapper', () => {
296
296
  getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
297
297
  getData: () => ({
298
298
  textPreview: 'Original text...',
299
- pageUrl: 'https://www.example.com',
299
+ url: 'https://www.example.com',
300
300
  scrapedAt: '2025-09-20T06:21:12.584Z',
301
301
  transformRules: {
302
302
  value: 'Improved readability text',
@@ -319,7 +319,7 @@ describe('ReadabilityMapper', () => {
319
319
  getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
320
320
  getData: () => ({
321
321
  textPreview: 'Text...',
322
- pageUrl: 'https://www.example.com',
322
+ url: 'https://www.example.com',
323
323
  transformRules: {
324
324
  value: 'Better text',
325
325
  op: 'replace',
@@ -341,7 +341,7 @@ describe('ReadabilityMapper', () => {
341
341
  getId: () => 'sugg-999',
342
342
  getData: () => ({
343
343
  textPreview: 'Text...',
344
- pageUrl: 'https://www.example.com',
344
+ url: 'https://www.example.com',
345
345
  // Missing transformRules
346
346
  }),
347
347
  };
@@ -364,7 +364,7 @@ describe('ReadabilityMapper', () => {
364
364
  getId: () => 'sugg-warn',
365
365
  getData: () => ({
366
366
  textPreview: 'Text...',
367
- pageUrl: 'https://www.example.com',
367
+ url: 'https://www.example.com',
368
368
  transformRules: {
369
369
  op: 'replace',
370
370
  // Missing selector and value
@@ -386,7 +386,7 @@ describe('ReadabilityMapper', () => {
386
386
  getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
387
387
  getData: () => ({
388
388
  textPreview: 'Original text 1',
389
- pageUrl: 'https://www.example.com/page1',
389
+ url: 'https://www.example.com/page1',
390
390
  transformRules: {
391
391
  value: 'First improved text',
392
392
  op: 'replace',
@@ -399,7 +399,7 @@ describe('ReadabilityMapper', () => {
399
399
  getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
400
400
  getData: () => ({
401
401
  textPreview: 'Original text 2',
402
- pageUrl: 'https://www.example.com/page2',
402
+ url: 'https://www.example.com/page2',
403
403
  transformRules: {
404
404
  value: 'Second improved text',
405
405
  op: 'replace',