@adobe/spacecat-shared-tokowaka-client 1.0.4 → 1.0.5
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 +7 -0
- package/package.json +1 -1
- package/src/index.js +202 -12
- package/src/utils/custom-html-utils.js +194 -0
- package/src/utils/s3-utils.js +5 -1
- package/test/index.test.js +396 -2
- package/test/mappers/headings-mapper.test.js +0 -2
- package/test/utils/html-utils.test.js +434 -0
- package/test/mappers/faq-mapper.test.js.backup +0 -1264
package/test/index.test.js
CHANGED
|
@@ -58,7 +58,10 @@ describe('TokowakaClient', () => {
|
|
|
58
58
|
getId: () => 'site-123',
|
|
59
59
|
getBaseURL: () => 'https://example.com',
|
|
60
60
|
getConfig: () => ({
|
|
61
|
-
getTokowakaConfig: () => ({
|
|
61
|
+
getTokowakaConfig: () => ({
|
|
62
|
+
apiKey: 'test-api-key-123',
|
|
63
|
+
forwardedHost: 'example.com',
|
|
64
|
+
}),
|
|
62
65
|
}),
|
|
63
66
|
};
|
|
64
67
|
|
|
@@ -104,7 +107,7 @@ describe('TokowakaClient', () => {
|
|
|
104
107
|
describe('constructor', () => {
|
|
105
108
|
it('should create an instance with valid config', () => {
|
|
106
109
|
expect(client).to.be.instanceOf(TokowakaClient);
|
|
107
|
-
expect(client.
|
|
110
|
+
expect(client.deployBucketName).to.equal('test-bucket');
|
|
108
111
|
expect(client.s3Client).to.equal(s3Client);
|
|
109
112
|
});
|
|
110
113
|
|
|
@@ -1147,6 +1150,397 @@ describe('TokowakaClient', () => {
|
|
|
1147
1150
|
});
|
|
1148
1151
|
});
|
|
1149
1152
|
|
|
1153
|
+
describe('previewSuggestions', () => {
|
|
1154
|
+
let fetchStub;
|
|
1155
|
+
|
|
1156
|
+
beforeEach(() => {
|
|
1157
|
+
// Stub global fetch for HTML fetching
|
|
1158
|
+
fetchStub = sinon.stub(global, 'fetch');
|
|
1159
|
+
// Mock fetch responses for HTML fetching (warmup + actual for both original and optimized)
|
|
1160
|
+
fetchStub.resolves({
|
|
1161
|
+
ok: true,
|
|
1162
|
+
status: 200,
|
|
1163
|
+
statusText: 'OK',
|
|
1164
|
+
headers: {
|
|
1165
|
+
get: (name) => (name === 'x-tokowaka-cache' ? 'HIT' : null),
|
|
1166
|
+
},
|
|
1167
|
+
text: async () => '<html><body>Test HTML</body></html>',
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
// Stub CDN invalidation for preview tests
|
|
1171
|
+
sinon.stub(client, 'invalidateCdnCache').resolves({
|
|
1172
|
+
status: 'success',
|
|
1173
|
+
provider: 'cloudfront',
|
|
1174
|
+
invalidationId: 'I123',
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
// Stub fetchConfig to return null by default (no existing config)
|
|
1178
|
+
sinon.stub(client, 'fetchConfig').resolves(null);
|
|
1179
|
+
|
|
1180
|
+
// Add TOKOWAKA_EDGE_URL to env
|
|
1181
|
+
client.env.TOKOWAKA_EDGE_URL = 'https://edge-dev.tokowaka.now';
|
|
1182
|
+
client.previewBucketName = 'test-preview-bucket';
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
afterEach(() => {
|
|
1186
|
+
// fetchStub will be restored by global afterEach sinon.restore()
|
|
1187
|
+
// Just clean up env changes
|
|
1188
|
+
delete client.env.TOKOWAKA_EDGE_URL;
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
it('should preview suggestions successfully with HTML', async () => {
|
|
1192
|
+
const result = await client.previewSuggestions(
|
|
1193
|
+
mockSite,
|
|
1194
|
+
mockOpportunity,
|
|
1195
|
+
mockSuggestions,
|
|
1196
|
+
{ warmupDelayMs: 0 },
|
|
1197
|
+
);
|
|
1198
|
+
|
|
1199
|
+
expect(result).to.have.property('s3Path', 'preview/opportunities/test-api-key-123');
|
|
1200
|
+
expect(result).to.have.property('succeededSuggestions');
|
|
1201
|
+
expect(result.succeededSuggestions).to.have.length(2);
|
|
1202
|
+
expect(result).to.have.property('failedSuggestions');
|
|
1203
|
+
expect(result.failedSuggestions).to.have.length(0);
|
|
1204
|
+
expect(result).to.have.property('html');
|
|
1205
|
+
expect(result.html).to.have.property('url', 'https://example.com/page1');
|
|
1206
|
+
expect(result.html).to.have.property('originalHtml');
|
|
1207
|
+
expect(result.html).to.have.property('optimizedHtml');
|
|
1208
|
+
expect(result.html.originalHtml).to.equal('<html><body>Test HTML</body></html>');
|
|
1209
|
+
expect(result.html.optimizedHtml).to.equal('<html><body>Test HTML</body></html>');
|
|
1210
|
+
|
|
1211
|
+
// Verify fetch was called for HTML fetching
|
|
1212
|
+
// (4 times: warmup + actual for original and optimized)
|
|
1213
|
+
expect(fetchStub.callCount).to.equal(4);
|
|
1214
|
+
expect(s3Client.send).to.have.been.calledOnce;
|
|
1215
|
+
});
|
|
1216
|
+
|
|
1217
|
+
it('should throw error if TOKOWAKA_EDGE_URL is not configured', async () => {
|
|
1218
|
+
delete client.env.TOKOWAKA_EDGE_URL;
|
|
1219
|
+
|
|
1220
|
+
try {
|
|
1221
|
+
await client.previewSuggestions(mockSite, mockOpportunity, mockSuggestions);
|
|
1222
|
+
expect.fail('Should have thrown error');
|
|
1223
|
+
} catch (error) {
|
|
1224
|
+
expect(error.message).to.include('TOKOWAKA_EDGE_URL is required for preview');
|
|
1225
|
+
expect(error.status).to.equal(500);
|
|
1226
|
+
}
|
|
1227
|
+
});
|
|
1228
|
+
|
|
1229
|
+
it('should throw error if site does not have Tokowaka API key', async () => {
|
|
1230
|
+
mockSite.getConfig = () => ({
|
|
1231
|
+
getTokowakaConfig: () => ({ forwardedHost: 'example.com' }),
|
|
1232
|
+
});
|
|
1233
|
+
|
|
1234
|
+
try {
|
|
1235
|
+
await client.previewSuggestions(mockSite, mockOpportunity, mockSuggestions);
|
|
1236
|
+
expect.fail('Should have thrown error');
|
|
1237
|
+
} catch (error) {
|
|
1238
|
+
expect(error.message).to.include('Tokowaka API key or forwarded host configured');
|
|
1239
|
+
expect(error.status).to.equal(400);
|
|
1240
|
+
}
|
|
1241
|
+
});
|
|
1242
|
+
|
|
1243
|
+
it('should throw error if site getConfig returns null', async () => {
|
|
1244
|
+
mockSite.getConfig = () => null;
|
|
1245
|
+
|
|
1246
|
+
try {
|
|
1247
|
+
await client.previewSuggestions(mockSite, mockOpportunity, mockSuggestions);
|
|
1248
|
+
expect.fail('Should have thrown error');
|
|
1249
|
+
} catch (error) {
|
|
1250
|
+
expect(error.message).to.include('Tokowaka API key or forwarded host configured');
|
|
1251
|
+
expect(error.status).to.equal(400);
|
|
1252
|
+
}
|
|
1253
|
+
});
|
|
1254
|
+
|
|
1255
|
+
it('should throw error for unsupported opportunity type', async () => {
|
|
1256
|
+
mockOpportunity.getType = () => 'unsupported-type';
|
|
1257
|
+
|
|
1258
|
+
try {
|
|
1259
|
+
await client.previewSuggestions(mockSite, mockOpportunity, mockSuggestions);
|
|
1260
|
+
expect.fail('Should have thrown error');
|
|
1261
|
+
} catch (error) {
|
|
1262
|
+
expect(error.message).to.include('No mapper found for opportunity type');
|
|
1263
|
+
expect(error.status).to.equal(501);
|
|
1264
|
+
}
|
|
1265
|
+
});
|
|
1266
|
+
|
|
1267
|
+
it('should handle ineligible suggestions', async () => {
|
|
1268
|
+
mockSuggestions = [
|
|
1269
|
+
{
|
|
1270
|
+
getId: () => 'sugg-1',
|
|
1271
|
+
getData: () => ({
|
|
1272
|
+
url: 'https://example.com/page1',
|
|
1273
|
+
recommendedAction: 'New Heading',
|
|
1274
|
+
checkType: 'heading-missing', // Not eligible
|
|
1275
|
+
}),
|
|
1276
|
+
},
|
|
1277
|
+
];
|
|
1278
|
+
|
|
1279
|
+
const result = await client.previewSuggestions(
|
|
1280
|
+
mockSite,
|
|
1281
|
+
mockOpportunity,
|
|
1282
|
+
mockSuggestions,
|
|
1283
|
+
);
|
|
1284
|
+
|
|
1285
|
+
expect(result.succeededSuggestions).to.have.length(0);
|
|
1286
|
+
expect(result.failedSuggestions).to.have.length(1);
|
|
1287
|
+
expect(result.config).to.be.null;
|
|
1288
|
+
});
|
|
1289
|
+
|
|
1290
|
+
it('should return early when generateConfig produces empty optimizations', async () => {
|
|
1291
|
+
// Use suggestions that pass eligibility but fail during config generation
|
|
1292
|
+
mockSuggestions = [
|
|
1293
|
+
{
|
|
1294
|
+
getId: () => 'sugg-no-url',
|
|
1295
|
+
getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
|
|
1296
|
+
getData: () => ({
|
|
1297
|
+
// Missing URL - will be skipped in generateConfig
|
|
1298
|
+
recommendedAction: 'New Heading',
|
|
1299
|
+
checkType: 'heading-empty',
|
|
1300
|
+
transformRules: {
|
|
1301
|
+
action: 'replace',
|
|
1302
|
+
selector: 'h1',
|
|
1303
|
+
},
|
|
1304
|
+
}),
|
|
1305
|
+
},
|
|
1306
|
+
];
|
|
1307
|
+
|
|
1308
|
+
const result = await client.previewSuggestions(
|
|
1309
|
+
mockSite,
|
|
1310
|
+
mockOpportunity,
|
|
1311
|
+
mockSuggestions,
|
|
1312
|
+
{ warmupDelayMs: 0 },
|
|
1313
|
+
);
|
|
1314
|
+
|
|
1315
|
+
expect(result.succeededSuggestions).to.have.length(0);
|
|
1316
|
+
expect(result.failedSuggestions).to.have.length(1);
|
|
1317
|
+
expect(result.config).to.be.null;
|
|
1318
|
+
expect(log.warn).to.have.been.calledWith('No eligible suggestions to preview');
|
|
1319
|
+
});
|
|
1320
|
+
|
|
1321
|
+
it('should merge with existing deployed patches for the same URL', async () => {
|
|
1322
|
+
// Setup existing config with deployed patches
|
|
1323
|
+
const existingConfig = {
|
|
1324
|
+
siteId: 'site-123',
|
|
1325
|
+
baseURL: 'https://example.com',
|
|
1326
|
+
version: '1.0',
|
|
1327
|
+
tokowakaForceFail: false,
|
|
1328
|
+
tokowakaOptimizations: {
|
|
1329
|
+
'/page1': {
|
|
1330
|
+
prerender: true,
|
|
1331
|
+
patches: [
|
|
1332
|
+
{
|
|
1333
|
+
op: 'replace',
|
|
1334
|
+
selector: 'title',
|
|
1335
|
+
value: 'Deployed Title',
|
|
1336
|
+
opportunityId: 'opp-456',
|
|
1337
|
+
suggestionId: 'sugg-deployed',
|
|
1338
|
+
prerenderRequired: true,
|
|
1339
|
+
lastUpdated: 1234567890,
|
|
1340
|
+
},
|
|
1341
|
+
],
|
|
1342
|
+
},
|
|
1343
|
+
},
|
|
1344
|
+
};
|
|
1345
|
+
|
|
1346
|
+
client.fetchConfig.resolves(existingConfig);
|
|
1347
|
+
|
|
1348
|
+
const result = await client.previewSuggestions(
|
|
1349
|
+
mockSite,
|
|
1350
|
+
mockOpportunity,
|
|
1351
|
+
mockSuggestions,
|
|
1352
|
+
{ warmupDelayMs: 0 },
|
|
1353
|
+
);
|
|
1354
|
+
|
|
1355
|
+
expect(result.succeededSuggestions).to.have.length(2);
|
|
1356
|
+
|
|
1357
|
+
// Verify config was uploaded with merged patches
|
|
1358
|
+
const uploadedConfig = JSON.parse(s3Client.send.firstCall.args[0].input.Body);
|
|
1359
|
+
expect(uploadedConfig.tokowakaOptimizations['/page1'].patches).to.have.length(3);
|
|
1360
|
+
|
|
1361
|
+
// Should have existing deployed patch + 2 new preview patches
|
|
1362
|
+
const deployedPatch = uploadedConfig.tokowakaOptimizations['/page1'].patches
|
|
1363
|
+
.find((p) => p.suggestionId === 'sugg-deployed');
|
|
1364
|
+
expect(deployedPatch).to.exist;
|
|
1365
|
+
expect(deployedPatch.value).to.equal('Deployed Title');
|
|
1366
|
+
});
|
|
1367
|
+
|
|
1368
|
+
it('should handle existing config with no patches for preview URL', async () => {
|
|
1369
|
+
// Setup existing config with patches for a different URL
|
|
1370
|
+
const existingConfig = {
|
|
1371
|
+
siteId: 'site-123',
|
|
1372
|
+
baseURL: 'https://example.com',
|
|
1373
|
+
version: '1.0',
|
|
1374
|
+
tokowakaForceFail: false,
|
|
1375
|
+
tokowakaOptimizations: {
|
|
1376
|
+
'/other-page': {
|
|
1377
|
+
prerender: true,
|
|
1378
|
+
patches: [
|
|
1379
|
+
{
|
|
1380
|
+
op: 'replace',
|
|
1381
|
+
selector: 'title',
|
|
1382
|
+
value: 'Other Page Title',
|
|
1383
|
+
opportunityId: 'opp-999',
|
|
1384
|
+
suggestionId: 'sugg-other',
|
|
1385
|
+
prerenderRequired: true,
|
|
1386
|
+
lastUpdated: 1234567890,
|
|
1387
|
+
},
|
|
1388
|
+
],
|
|
1389
|
+
},
|
|
1390
|
+
},
|
|
1391
|
+
};
|
|
1392
|
+
|
|
1393
|
+
client.fetchConfig.resolves(existingConfig);
|
|
1394
|
+
|
|
1395
|
+
const result = await client.previewSuggestions(
|
|
1396
|
+
mockSite,
|
|
1397
|
+
mockOpportunity,
|
|
1398
|
+
mockSuggestions,
|
|
1399
|
+
{ warmupDelayMs: 0 },
|
|
1400
|
+
);
|
|
1401
|
+
|
|
1402
|
+
expect(result.succeededSuggestions).to.have.length(2);
|
|
1403
|
+
expect(log.info).to.have.been.calledWith(sinon.match(/No deployed patches found/));
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1406
|
+
it('should throw error when HTML fetch fails', async () => {
|
|
1407
|
+
// Mock fetch to fail
|
|
1408
|
+
fetchStub.rejects(new Error('Network error'));
|
|
1409
|
+
|
|
1410
|
+
try {
|
|
1411
|
+
await client.previewSuggestions(mockSite, mockOpportunity, mockSuggestions);
|
|
1412
|
+
expect.fail('Should have thrown error');
|
|
1413
|
+
} catch (error) {
|
|
1414
|
+
expect(error.message).to.include('Preview failed');
|
|
1415
|
+
expect(error.status).to.equal(500);
|
|
1416
|
+
}
|
|
1417
|
+
});
|
|
1418
|
+
|
|
1419
|
+
it('should retry HTML fetch on failure', async () => {
|
|
1420
|
+
// First 2 calls fail (warmup succeeds, first actual call fails)
|
|
1421
|
+
// Then succeed on retry
|
|
1422
|
+
fetchStub.onCall(0).resolves({ // warmup original
|
|
1423
|
+
ok: true,
|
|
1424
|
+
status: 200,
|
|
1425
|
+
statusText: 'OK',
|
|
1426
|
+
headers: {
|
|
1427
|
+
get: () => null,
|
|
1428
|
+
},
|
|
1429
|
+
text: async () => 'warmup',
|
|
1430
|
+
});
|
|
1431
|
+
fetchStub.onCall(1).rejects(new Error('Temporary failure')); // actual original - fail
|
|
1432
|
+
fetchStub.onCall(2).resolves({ // retry original - success
|
|
1433
|
+
ok: true,
|
|
1434
|
+
status: 200,
|
|
1435
|
+
statusText: 'OK',
|
|
1436
|
+
headers: {
|
|
1437
|
+
get: (name) => (name === 'x-tokowaka-cache' ? 'HIT' : null),
|
|
1438
|
+
},
|
|
1439
|
+
text: async () => '<html>Original</html>',
|
|
1440
|
+
});
|
|
1441
|
+
fetchStub.onCall(3).resolves({ // warmup optimized
|
|
1442
|
+
ok: true,
|
|
1443
|
+
status: 200,
|
|
1444
|
+
statusText: 'OK',
|
|
1445
|
+
headers: {
|
|
1446
|
+
get: () => null,
|
|
1447
|
+
},
|
|
1448
|
+
text: async () => 'warmup',
|
|
1449
|
+
});
|
|
1450
|
+
fetchStub.onCall(4).resolves({ // actual optimized
|
|
1451
|
+
ok: true,
|
|
1452
|
+
status: 200,
|
|
1453
|
+
statusText: 'OK',
|
|
1454
|
+
headers: {
|
|
1455
|
+
get: (name) => (name === 'x-tokowaka-cache' ? 'HIT' : null),
|
|
1456
|
+
},
|
|
1457
|
+
text: async () => '<html>Optimized</html>',
|
|
1458
|
+
});
|
|
1459
|
+
|
|
1460
|
+
const result = await client.previewSuggestions(
|
|
1461
|
+
mockSite,
|
|
1462
|
+
mockOpportunity,
|
|
1463
|
+
mockSuggestions,
|
|
1464
|
+
{ warmupDelayMs: 0, retryDelayMs: 0 },
|
|
1465
|
+
);
|
|
1466
|
+
|
|
1467
|
+
expect(result.html.originalHtml).to.equal('<html>Original</html>');
|
|
1468
|
+
expect(result.html.optimizedHtml).to.equal('<html>Optimized</html>');
|
|
1469
|
+
expect(fetchStub.callCount).to.be.at.least(5);
|
|
1470
|
+
});
|
|
1471
|
+
|
|
1472
|
+
it('should use forwardedHost from site config', async () => {
|
|
1473
|
+
mockSite.getConfig = () => ({
|
|
1474
|
+
getTokowakaConfig: () => ({
|
|
1475
|
+
apiKey: 'test-api-key-123',
|
|
1476
|
+
forwardedHost: 'custom.example.com',
|
|
1477
|
+
}),
|
|
1478
|
+
});
|
|
1479
|
+
|
|
1480
|
+
await client.previewSuggestions(
|
|
1481
|
+
mockSite,
|
|
1482
|
+
mockOpportunity,
|
|
1483
|
+
mockSuggestions,
|
|
1484
|
+
{ warmupDelayMs: 0 },
|
|
1485
|
+
);
|
|
1486
|
+
|
|
1487
|
+
// Check that fetch was called with correct headers
|
|
1488
|
+
const actualCall = fetchStub.getCalls().find((call) => {
|
|
1489
|
+
const headers = call.args[1]?.headers;
|
|
1490
|
+
return headers && headers['x-forwarded-host'] === 'custom.example.com';
|
|
1491
|
+
});
|
|
1492
|
+
|
|
1493
|
+
expect(actualCall).to.exist;
|
|
1494
|
+
});
|
|
1495
|
+
|
|
1496
|
+
it('should throw error when forwardedHost is not configured', async () => {
|
|
1497
|
+
mockSite.getConfig = () => ({
|
|
1498
|
+
getTokowakaConfig: () => ({
|
|
1499
|
+
apiKey: 'test-api-key-123',
|
|
1500
|
+
// forwardedHost is missing
|
|
1501
|
+
}),
|
|
1502
|
+
});
|
|
1503
|
+
|
|
1504
|
+
try {
|
|
1505
|
+
await client.previewSuggestions(mockSite, mockOpportunity, mockSuggestions);
|
|
1506
|
+
expect.fail('Should have thrown error');
|
|
1507
|
+
} catch (error) {
|
|
1508
|
+
expect(error.message).to.include('Tokowaka API key or forwarded host configured');
|
|
1509
|
+
expect(error.status).to.equal(400);
|
|
1510
|
+
}
|
|
1511
|
+
});
|
|
1512
|
+
|
|
1513
|
+
it('should upload config to preview S3 path', async () => {
|
|
1514
|
+
await client.previewSuggestions(
|
|
1515
|
+
mockSite,
|
|
1516
|
+
mockOpportunity,
|
|
1517
|
+
mockSuggestions,
|
|
1518
|
+
{ warmupDelayMs: 0 },
|
|
1519
|
+
);
|
|
1520
|
+
|
|
1521
|
+
expect(s3Client.send).to.have.been.calledOnce;
|
|
1522
|
+
|
|
1523
|
+
const putCommand = s3Client.send.firstCall.args[0];
|
|
1524
|
+
expect(putCommand.input.Bucket).to.equal('test-preview-bucket');
|
|
1525
|
+
expect(putCommand.input.Key).to.equal('preview/opportunities/test-api-key-123');
|
|
1526
|
+
});
|
|
1527
|
+
|
|
1528
|
+
it('should invalidate CDN cache for preview path', async () => {
|
|
1529
|
+
await client.previewSuggestions(
|
|
1530
|
+
mockSite,
|
|
1531
|
+
mockOpportunity,
|
|
1532
|
+
mockSuggestions,
|
|
1533
|
+
{ warmupDelayMs: 0 },
|
|
1534
|
+
);
|
|
1535
|
+
|
|
1536
|
+
expect(client.invalidateCdnCache).to.have.been.calledWith(
|
|
1537
|
+
'test-api-key-123',
|
|
1538
|
+
'cloudfront',
|
|
1539
|
+
true,
|
|
1540
|
+
);
|
|
1541
|
+
});
|
|
1542
|
+
});
|
|
1543
|
+
|
|
1150
1544
|
describe('invalidateCdnCache', () => {
|
|
1151
1545
|
let mockCdnClient;
|
|
1152
1546
|
|
|
@@ -363,7 +363,6 @@ describe('HeadingsMapper', () => {
|
|
|
363
363
|
|
|
364
364
|
const patches = mapper.suggestionsToPatches('/path', [suggestion], 'opp-999');
|
|
365
365
|
expect(patches.length).to.equal(0);
|
|
366
|
-
expect(patches.length).to.equal(0);
|
|
367
366
|
});
|
|
368
367
|
|
|
369
368
|
it('should return empty array for heading-h1-length without selector in transformRules', () => {
|
|
@@ -380,7 +379,6 @@ describe('HeadingsMapper', () => {
|
|
|
380
379
|
|
|
381
380
|
const patches = mapper.suggestionsToPatches('/path', [suggestion], 'opp-888');
|
|
382
381
|
expect(patches.length).to.equal(0);
|
|
383
|
-
expect(patches.length).to.equal(0);
|
|
384
382
|
});
|
|
385
383
|
|
|
386
384
|
it('should log warning for heading-missing-h1 with missing transformRules - validation path', () => {
|