@adobe/spacecat-shared-tokowaka-client 1.18.0 → 1.18.2

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,15 @@
1
+ ## [@adobe/spacecat-shared-tokowaka-client-v1.18.2](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.18.1...@adobe/spacecat-shared-tokowaka-client-v1.18.2) (2026-06-02)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **tokowaka-client:** use scraperConfig headers in edge optimize probe (LLMO-5280) ([#1647](https://github.com/adobe/spacecat-shared/issues/1647)) ([6be14a1](https://github.com/adobe/spacecat-shared/commit/6be14a153ea8d9de010880afaa1c3c9290a32f13))
6
+
7
+ ## [@adobe/spacecat-shared-tokowaka-client-v1.18.1](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.18.0...@adobe/spacecat-shared-tokowaka-client-v1.18.1) (2026-06-01)
8
+
9
+ ### Bug Fixes
10
+
11
+ * tighten buildUrlMatcher boundary check and add dedicated unit tests ([#1646](https://github.com/adobe/spacecat-shared/issues/1646)) ([e24495a](https://github.com/adobe/spacecat-shared/commit/e24495af9c646700edde86cba1e85441cc07bf31)), closes [#1598](https://github.com/adobe/spacecat-shared/issues/1598)
12
+
1
13
  ## [@adobe/spacecat-shared-tokowaka-client-v1.18.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.17.3...@adobe/spacecat-shared-tokowaka-client-v1.18.0) (2026-06-01)
2
14
 
3
15
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/spacecat-shared-tokowaka-client",
3
- "version": "1.18.0",
3
+ "version": "1.18.2",
4
4
  "description": "Tokowaka Client for SpaceCat - Edge optimization config management",
5
5
  "type": "module",
6
6
  "engines": {
package/src/index.js CHANGED
@@ -1287,10 +1287,18 @@ class TokowakaClient {
1287
1287
  return { edgeOptimizeEnabled: true };
1288
1288
  }
1289
1289
 
1290
+ const defaultProbeHeaders = {
1291
+ // eslint-disable-next-line max-len
1292
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Tokowaka-AI Tokowaka/1.0 AdobeEdgeOptimize-AI AdobeEdgeOptimize/1.0',
1293
+ 'fastly-debug': '1',
1294
+ };
1295
+ const customHeaders = currentConfig?.getScraperConfig?.()?.headers ?? {};
1296
+ const probeHeaders = { ...customHeaders, ...defaultProbeHeaders };
1297
+
1290
1298
  const baseURL = getEffectiveBaseURL(site);
1291
1299
  const targetUrl = new URL(path, baseURL).toString();
1292
1300
 
1293
- this.log.info(`Checking edge optimize status for ${targetUrl}`);
1301
+ this.log.info(`[edge-optimize-status] Checking edge optimize status for ${targetUrl}`);
1294
1302
 
1295
1303
  const maxRetries = 3;
1296
1304
  let attempt = 0;
@@ -1300,25 +1308,21 @@ class TokowakaClient {
1300
1308
  while (attempt <= maxRetries) {
1301
1309
  try {
1302
1310
  // eslint-disable-next-line max-len
1303
- this.log.debug(`Attempt ${attempt + 1}/${maxRetries + 1}: Checking edge optimize status for ${targetUrl}`);
1311
+ this.log.debug(`[edge-optimize-status] Attempt ${attempt + 1}/${maxRetries + 1}: Checking edge optimize status for ${targetUrl}`);
1304
1312
 
1305
1313
  // eslint-disable-next-line no-await-in-loop
1306
1314
  const response = await tracingFetch(targetUrl, {
1307
1315
  method: 'GET',
1308
- headers: {
1309
- // eslint-disable-next-line max-len
1310
- 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Tokowaka-AI Tokowaka/1.0 AdobeEdgeOptimize-AI AdobeEdgeOptimize/1.0',
1311
- 'fastly-debug': '1',
1312
- },
1316
+ headers: probeHeaders,
1313
1317
  timeout: REQUEST_TIMEOUT_MS,
1314
1318
  });
1315
1319
 
1316
- this.log.debug(`Response status: ${response.status}`);
1320
+ this.log.info(`[edge-optimize-status] Response status: ${response.status} for ${targetUrl}`);
1317
1321
 
1318
1322
  const edgeOptimizeEnabled = response.headers.get('x-tokowaka-request-id') !== null
1319
1323
  || response.headers.get('x-edgeoptimize-request-id') !== null;
1320
1324
 
1321
- this.log.debug(`Edge optimize headers found: ${edgeOptimizeEnabled}`);
1325
+ this.log.info(`[edge-optimize-status] Edge optimize headers found: ${edgeOptimizeEnabled} for ${targetUrl}`);
1322
1326
 
1323
1327
  return {
1324
1328
  edgeOptimizeEnabled,
@@ -1328,7 +1332,7 @@ class TokowakaClient {
1328
1332
 
1329
1333
  if (isTimeout) {
1330
1334
  // eslint-disable-next-line max-len
1331
- this.log.warn(`Request timed out after ${REQUEST_TIMEOUT_MS}ms for ${targetUrl}, returning edgeOptimizeEnabled: false`);
1335
+ this.log.warn(`[edge-optimize-status] Request timed out after ${REQUEST_TIMEOUT_MS}ms for ${targetUrl}, returning edgeOptimizeEnabled: false`);
1332
1336
  return { edgeOptimizeEnabled: false };
1333
1337
  }
1334
1338
 
@@ -1336,7 +1340,7 @@ class TokowakaClient {
1336
1340
 
1337
1341
  if (attempt > maxRetries) {
1338
1342
  // All retries exhausted
1339
- this.log.error(`Failed after ${maxRetries + 1} attempts: ${error.message}`);
1343
+ this.log.error(`[edge-optimize-status] Failed after ${maxRetries + 1} attempts: ${error.message} for ${targetUrl}`);
1340
1344
  throw this.#createError(
1341
1345
  `Failed to check edge optimize status: ${error.message}`,
1342
1346
  HTTP_INTERNAL_SERVER_ERROR,
@@ -1346,7 +1350,7 @@ class TokowakaClient {
1346
1350
  // Exponential backoff: 200ms, 400ms, 800ms
1347
1351
  const delay = 100 * (2 ** attempt);
1348
1352
  this.log.warn(
1349
- `Attempt ${attempt} to fetch failed: ${error.message}. Retrying in ${delay}ms...`,
1353
+ `[edge-optimize-status] Attempt ${attempt} to fetch failed: ${error.message}. Retrying in ${delay}ms... for ${targetUrl}`,
1350
1354
  );
1351
1355
  // eslint-disable-next-line no-await-in-loop
1352
1356
  await new Promise((res) => {
@@ -12,11 +12,17 @@
12
12
 
13
13
  /**
14
14
  * Builds a URL matcher function for a given allowedRegexPatterns entry.
15
- * Patterns ending with `/*` use pathname prefix matching (e.g. `/products/*` matches `/products/`).
16
- * When the pattern is `/*`, the prefix becomes '' and matches all URLs
17
- * (intentional for domain-wide).
18
- * Other patterns are compiled as regular expressions for backward compatibility.
19
- * Returns null for patterns that cannot be compiled.
15
+ *
16
+ * - Patterns ending with `/*` use pathname prefix matching against the URL's pathname.
17
+ * `/products/*` matches `/products`, `/products/`, `/products/item` but NOT `/productsabc`.
18
+ * `/*` matches all URLs (intentional for domain-wide).
19
+ * - All other patterns are compiled as regular expressions and matched against the **full URL
20
+ * string** (e.g. `https://example.com/page`). This is intentional for backward compatibility
21
+ * with callers that supply full-URL regexes such as `^https://example\\.com/.*`.
22
+ * Note: a pathname-only regex like `^/blog` will never match via this path because the URL
23
+ * string always starts with a scheme. Use the `/*` suffix form for pathname-only matching.
24
+ * - Returns null for non-string, empty, or invalid-regex inputs.
25
+ *
20
26
  * @param {string} pattern
21
27
  * @returns {((url: string) => boolean)|null}
22
28
  */
@@ -28,7 +34,9 @@ export function buildUrlMatcher(pattern) {
28
34
  const prefix = pattern.slice(0, -2); // '/products/*' → '/products', '/*' → ''
29
35
  return (url) => {
30
36
  try {
31
- return new URL(url).pathname.startsWith(prefix);
37
+ const { pathname } = new URL(url);
38
+ // Empty prefix means '/*' — match everything
39
+ return prefix === '' || pathname === prefix || pathname.startsWith(`${prefix}/`);
32
40
  } catch {
33
41
  return false;
34
42
  }
@@ -5035,6 +5035,165 @@ describe('TokowakaClient', () => {
5035
5035
  expect(userAgent).to.include('Mozilla/5.0');
5036
5036
  });
5037
5037
 
5038
+ it('should ignore custom User-Agent from scraperConfig — default takes priority', async () => {
5039
+ const customSite = {
5040
+ getId: () => 'site-id',
5041
+ getBaseURL: () => 'https://example.com',
5042
+ getConfig: () => ({
5043
+ getEdgeOptimizeConfig: () => undefined,
5044
+ getScraperConfig: () => ({ headers: { 'User-Agent': 'AllowListedBot/1.0' } }),
5045
+ }),
5046
+ getDeliveryType: () => 'aem_edge',
5047
+ };
5048
+ const mockResponse = {
5049
+ status: 200,
5050
+ headers: new Map(),
5051
+ };
5052
+ mockResponse.headers.get = () => null;
5053
+
5054
+ tracingFetchStub.resolves(mockResponse);
5055
+
5056
+ await esmockClient.checkEdgeOptimizeStatus(customSite, '/');
5057
+
5058
+ const fetchOptions = tracingFetchStub.firstCall.args[1];
5059
+ expect(fetchOptions.headers['User-Agent']).to.include('Tokowaka-AI');
5060
+ expect(fetchOptions.headers['User-Agent']).to.not.include('AllowListedBot/1.0');
5061
+ });
5062
+
5063
+ it('should add extra custom headers while defaults always win on collision', async () => {
5064
+ const customSite = {
5065
+ getId: () => 'site-id',
5066
+ getBaseURL: () => 'https://example.com',
5067
+ getConfig: () => ({
5068
+ getEdgeOptimizeConfig: () => undefined,
5069
+ getScraperConfig: () => ({ headers: { 'x-allowlist-token': 'secret123', 'fastly-debug': 'custom' } }),
5070
+ }),
5071
+ getDeliveryType: () => 'aem_edge',
5072
+ };
5073
+ const mockResponse = {
5074
+ status: 200,
5075
+ headers: new Map(),
5076
+ };
5077
+ mockResponse.headers.get = () => null;
5078
+
5079
+ tracingFetchStub.resolves(mockResponse);
5080
+
5081
+ await esmockClient.checkEdgeOptimizeStatus(customSite, '/');
5082
+
5083
+ const fetchOptions = tracingFetchStub.firstCall.args[1];
5084
+ expect(fetchOptions.headers['x-allowlist-token']).to.equal('secret123');
5085
+ expect(fetchOptions.headers['fastly-debug']).to.equal('1');
5086
+ expect(fetchOptions.headers['User-Agent']).to.include('Mozilla/5.0');
5087
+ });
5088
+
5089
+ it('should use default probe headers when scraperConfig has no headers', async () => {
5090
+ const noHeadersSite = {
5091
+ getId: () => 'site-id',
5092
+ getBaseURL: () => 'https://example.com',
5093
+ getConfig: () => ({
5094
+ getEdgeOptimizeConfig: () => undefined,
5095
+ getScraperConfig: () => ({}),
5096
+ }),
5097
+ getDeliveryType: () => 'aem_edge',
5098
+ };
5099
+ const mockResponse = {
5100
+ status: 200,
5101
+ headers: new Map(),
5102
+ };
5103
+ mockResponse.headers.get = () => null;
5104
+
5105
+ tracingFetchStub.resolves(mockResponse);
5106
+
5107
+ await esmockClient.checkEdgeOptimizeStatus(noHeadersSite, '/');
5108
+
5109
+ const fetchOptions = tracingFetchStub.firstCall.args[1];
5110
+ expect(fetchOptions.headers['User-Agent']).to.include('Tokowaka-AI');
5111
+ expect(fetchOptions.headers['fastly-debug']).to.equal('1');
5112
+ });
5113
+
5114
+ it('should use default probe headers when getScraperConfig is not present on config', async () => {
5115
+ const noScraperSite = {
5116
+ getId: () => 'site-id',
5117
+ getBaseURL: () => 'https://example.com',
5118
+ getConfig: () => ({ getEdgeOptimizeConfig: () => undefined }),
5119
+ getDeliveryType: () => 'aem_edge',
5120
+ };
5121
+ const mockResponse = {
5122
+ status: 200,
5123
+ headers: new Map(),
5124
+ };
5125
+ mockResponse.headers.get = () => null;
5126
+
5127
+ tracingFetchStub.resolves(mockResponse);
5128
+
5129
+ await esmockClient.checkEdgeOptimizeStatus(noScraperSite, '/');
5130
+
5131
+ const fetchOptions = tracingFetchStub.firstCall.args[1];
5132
+ expect(fetchOptions.headers['User-Agent']).to.include('AdobeEdgeOptimize-AI');
5133
+ expect(fetchOptions.headers['fastly-debug']).to.equal('1');
5134
+ });
5135
+
5136
+ it('should use default probe headers when getScraperConfig returns null', async () => {
5137
+ const nullScraperSite = {
5138
+ getId: () => 'site-id',
5139
+ getBaseURL: () => 'https://example.com',
5140
+ getConfig: () => ({
5141
+ getEdgeOptimizeConfig: () => undefined,
5142
+ getScraperConfig: () => null,
5143
+ }),
5144
+ getDeliveryType: () => 'aem_edge',
5145
+ };
5146
+ const mockResponse = { status: 200, headers: new Map() };
5147
+ mockResponse.headers.get = () => null;
5148
+ tracingFetchStub.resolves(mockResponse);
5149
+
5150
+ await esmockClient.checkEdgeOptimizeStatus(nullScraperSite, '/');
5151
+
5152
+ const fetchOptions = tracingFetchStub.firstCall.args[1];
5153
+ expect(fetchOptions.headers['User-Agent']).to.include('Tokowaka-AI');
5154
+ expect(fetchOptions.headers['fastly-debug']).to.equal('1');
5155
+ });
5156
+
5157
+ it('should use default probe headers when getConfig returns null', async () => {
5158
+ const nullConfigSite = {
5159
+ getId: () => 'site-id',
5160
+ getBaseURL: () => 'https://example.com',
5161
+ getConfig: () => null,
5162
+ getDeliveryType: () => 'aem_edge',
5163
+ };
5164
+ const mockResponse = { status: 200, headers: new Map() };
5165
+ mockResponse.headers.get = () => null;
5166
+ tracingFetchStub.resolves(mockResponse);
5167
+
5168
+ await esmockClient.checkEdgeOptimizeStatus(nullConfigSite, '/');
5169
+
5170
+ const fetchOptions = tracingFetchStub.firstCall.args[1];
5171
+ expect(fetchOptions.headers['User-Agent']).to.include('Tokowaka-AI');
5172
+ expect(fetchOptions.headers['fastly-debug']).to.equal('1');
5173
+ });
5174
+
5175
+ it('should use default probe headers when scraperConfig has an empty headers object', async () => {
5176
+ const emptyHeadersSite = {
5177
+ getId: () => 'site-id',
5178
+ getBaseURL: () => 'https://example.com',
5179
+ getConfig: () => ({
5180
+ getEdgeOptimizeConfig: () => undefined,
5181
+ getScraperConfig: () => ({ headers: {} }),
5182
+ }),
5183
+ getDeliveryType: () => 'aem_edge',
5184
+ };
5185
+ const mockResponse = { status: 200, headers: new Map() };
5186
+ mockResponse.headers.get = () => null;
5187
+ tracingFetchStub.resolves(mockResponse);
5188
+
5189
+ await esmockClient.checkEdgeOptimizeStatus(emptyHeadersSite, '/');
5190
+
5191
+ const fetchOptions = tracingFetchStub.firstCall.args[1];
5192
+ expect(fetchOptions.headers['User-Agent']).to.include('Tokowaka-AI');
5193
+ expect(fetchOptions.headers['fastly-debug']).to.equal('1');
5194
+ expect(Object.keys(fetchOptions.headers)).to.have.lengthOf(2);
5195
+ });
5196
+
5038
5197
  it('should pass timeout option to tracingFetch', async () => {
5039
5198
  const mockResponse = {
5040
5199
  status: 200,
@@ -5168,7 +5327,7 @@ describe('TokowakaClient', () => {
5168
5327
  await promise;
5169
5328
 
5170
5329
  expect(log.warn).to.have.been.calledWith(
5171
- sinon.match(/Attempt 1 to fetch failed.*Connection refused.*Retrying in 200ms/),
5330
+ sinon.match(/\[edge-optimize-status\] Attempt 1 to fetch failed.*Connection refused.*Retrying in 200ms/),
5172
5331
  );
5173
5332
  });
5174
5333
 
@@ -5182,9 +5341,38 @@ describe('TokowakaClient', () => {
5182
5341
  expect(result).to.deep.equal({ edgeOptimizeEnabled: false });
5183
5342
  expect(tracingFetchStub).to.have.been.calledOnce;
5184
5343
  expect(log.warn).to.have.been.calledWith(
5185
- sinon.match(/Request timed out after 5000ms for https:\/\/example.com\/, returning edgeOptimizeEnabled: false/),
5344
+ sinon.match(/\[edge-optimize-status\] Request timed out after 5000ms for https:\/\/example.com\/, returning edgeOptimizeEnabled: false/),
5186
5345
  );
5187
5346
  });
5347
+
5348
+ it('should send the same custom headers on every retry attempt', async () => {
5349
+ const customSite = {
5350
+ getId: () => 'site-id',
5351
+ getBaseURL: () => 'https://example.com',
5352
+ getConfig: () => ({
5353
+ getEdgeOptimizeConfig: () => undefined,
5354
+ getScraperConfig: () => ({ headers: { 'x-allowlist-token': 'secret123' } }),
5355
+ }),
5356
+ getDeliveryType: () => 'aem_edge',
5357
+ };
5358
+ const mockResponse = { status: 200, headers: new Map() };
5359
+ mockResponse.headers.get = () => null;
5360
+
5361
+ tracingFetchStub.onFirstCall().rejects(new Error('Temporary failure'));
5362
+ tracingFetchStub.onSecondCall().resolves(mockResponse);
5363
+
5364
+ const promise = esmockClient.checkEdgeOptimizeStatus(customSite, '/');
5365
+ await clock.tickAsync(200);
5366
+ await promise;
5367
+
5368
+ expect(tracingFetchStub).to.have.been.calledTwice;
5369
+ const firstCallHeaders = tracingFetchStub.firstCall.args[1].headers;
5370
+ const secondCallHeaders = tracingFetchStub.secondCall.args[1].headers;
5371
+ expect(firstCallHeaders['x-allowlist-token']).to.equal('secret123');
5372
+ expect(secondCallHeaders['x-allowlist-token']).to.equal('secret123');
5373
+ expect(firstCallHeaders['User-Agent']).to.include('Tokowaka-AI');
5374
+ expect(secondCallHeaders['User-Agent']).to.include('Tokowaka-AI');
5375
+ });
5188
5376
  });
5189
5377
 
5190
5378
  describe('URL Construction', () => {
@@ -0,0 +1,69 @@
1
+ /*
2
+ * Copyright 2026 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 { expect } from 'chai';
14
+ import {
15
+ addPatternsToMetaconfig,
16
+ removePatternFromMetaconfig,
17
+ } from '../../src/utils/metaconfig-utils.js';
18
+
19
+ describe('metaconfig-utils', () => {
20
+ describe('addPatternsToMetaconfig', () => {
21
+ it('adds patterns to an empty allowList', () => {
22
+ const metaconfig = {};
23
+ const changed = addPatternsToMetaconfig(metaconfig, ['/products/*']);
24
+ expect(changed).to.be.true;
25
+ expect(metaconfig.prerender.allowList).to.deep.equal(['/products/*']);
26
+ });
27
+
28
+ it('appends patterns to an existing allowList', () => {
29
+ const metaconfig = { prerender: { allowList: ['/blog/*'] } };
30
+ addPatternsToMetaconfig(metaconfig, ['/products/*']);
31
+ expect(metaconfig.prerender.allowList).to.deep.equal(['/blog/*', '/products/*']);
32
+ });
33
+
34
+ it('deduplicates patterns already present', () => {
35
+ const metaconfig = { prerender: { allowList: ['/blog/*'] } };
36
+ const changed = addPatternsToMetaconfig(metaconfig, ['/blog/*']);
37
+ expect(changed).to.be.false;
38
+ expect(metaconfig.prerender.allowList).to.deep.equal(['/blog/*']);
39
+ });
40
+ });
41
+
42
+ describe('removePatternFromMetaconfig', () => {
43
+ it('removes a pattern from the allowList', () => {
44
+ const metaconfig = { prerender: { allowList: ['/blog/*', '/products/*'] } };
45
+ const changed = removePatternFromMetaconfig(metaconfig, '/blog/*');
46
+ expect(changed).to.be.true;
47
+ expect(metaconfig.prerender.allowList).to.deep.equal(['/products/*']);
48
+ });
49
+
50
+ it('returns false when the pattern is not in the allowList', () => {
51
+ const metaconfig = { prerender: { allowList: ['/blog/*'] } };
52
+ const changed = removePatternFromMetaconfig(metaconfig, '/products/*');
53
+ expect(changed).to.be.false;
54
+ expect(metaconfig.prerender.allowList).to.deep.equal(['/blog/*']);
55
+ });
56
+
57
+ it('deletes metaconfig.prerender entirely when allowList becomes empty', () => {
58
+ const metaconfig = { prerender: { allowList: ['/blog/*'] } };
59
+ removePatternFromMetaconfig(metaconfig, '/blog/*');
60
+ expect(metaconfig.prerender).to.be.undefined;
61
+ });
62
+
63
+ it('returns false when prerender is absent', () => {
64
+ const metaconfig = {};
65
+ const changed = removePatternFromMetaconfig(metaconfig, '/blog/*');
66
+ expect(changed).to.be.false;
67
+ });
68
+ });
69
+ });
@@ -0,0 +1,119 @@
1
+ /*
2
+ * Copyright 2026 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 { expect } from 'chai';
14
+ import { buildUrlMatcher } from '../../src/utils/pattern-utils.js';
15
+
16
+ describe('pattern-utils', () => {
17
+ describe('buildUrlMatcher', () => {
18
+ describe('invalid inputs', () => {
19
+ it('returns null for null', () => {
20
+ expect(buildUrlMatcher(null)).to.be.null;
21
+ });
22
+
23
+ it('returns null for undefined', () => {
24
+ expect(buildUrlMatcher(undefined)).to.be.null;
25
+ });
26
+
27
+ it('returns null for a number', () => {
28
+ expect(buildUrlMatcher(42)).to.be.null;
29
+ });
30
+
31
+ it('returns null for an empty string', () => {
32
+ expect(buildUrlMatcher('')).to.be.null;
33
+ });
34
+ });
35
+
36
+ describe('/* — domain-wide pattern', () => {
37
+ let match;
38
+ beforeEach(() => {
39
+ match = buildUrlMatcher('/*');
40
+ });
41
+
42
+ it('matches the root path', () => {
43
+ expect(match('https://example.com/')).to.be.true;
44
+ });
45
+
46
+ it('matches any sub-path', () => {
47
+ expect(match('https://example.com/products/item')).to.be.true;
48
+ expect(match('https://example.com/a/b/c')).to.be.true;
49
+ });
50
+
51
+ it('returns false for an invalid URL', () => {
52
+ expect(match('not-a-url')).to.be.false;
53
+ });
54
+ });
55
+
56
+ describe('/path/* — prefix patterns', () => {
57
+ let match;
58
+ beforeEach(() => {
59
+ match = buildUrlMatcher('/products/*');
60
+ });
61
+
62
+ it('matches a direct child path', () => {
63
+ expect(match('https://example.com/products/item')).to.be.true;
64
+ });
65
+
66
+ it('matches a nested child path', () => {
67
+ expect(match('https://example.com/products/sub/item')).to.be.true;
68
+ });
69
+
70
+ it('matches the exact prefix path without a trailing slash', () => {
71
+ expect(match('https://example.com/products')).to.be.true;
72
+ });
73
+
74
+ it('matches the exact prefix path with a trailing slash', () => {
75
+ expect(match('https://example.com/products/')).to.be.true;
76
+ });
77
+
78
+ it('does not over-match adjacent path segments (boundary safety)', () => {
79
+ expect(match('https://example.com/productsabc')).to.be.false;
80
+ expect(match('https://example.com/products-new')).to.be.false;
81
+ });
82
+
83
+ it('does not match an unrelated path', () => {
84
+ expect(match('https://example.com/blog/post')).to.be.false;
85
+ });
86
+
87
+ it('returns false for an invalid URL', () => {
88
+ expect(match('not-a-url')).to.be.false;
89
+ });
90
+ });
91
+
92
+ describe('regex patterns', () => {
93
+ it('matches against the full URL string (not pathname only) for backward compatibility', () => {
94
+ // Regex patterns are tested against the full URL (e.g. 'https://example.com/page').
95
+ // A pathname-only regex like '^/blog' will never match because the URL string starts with
96
+ // a scheme. Use the '/*' suffix form for pathname-only prefix matching.
97
+ const fullUrlMatch = buildUrlMatcher('^https://example\\.com/blog');
98
+ expect(fullUrlMatch('https://example.com/blog/post')).to.be.true;
99
+ expect(fullUrlMatch('https://example.com/news/post')).to.be.false;
100
+ });
101
+
102
+ it('matches a full-URL regex with wildcard', () => {
103
+ const match = buildUrlMatcher('^https://example\\.com/.*');
104
+ expect(match('https://example.com/products/item')).to.be.true;
105
+ expect(match('https://other.com/products/item')).to.be.false;
106
+ });
107
+
108
+ it('matches a simple substring against the full URL', () => {
109
+ const match = buildUrlMatcher('/products');
110
+ expect(match('https://example.com/products/item')).to.be.true;
111
+ expect(match('https://example.com/blog')).to.be.false;
112
+ });
113
+
114
+ it('returns null for an invalid regex pattern', () => {
115
+ expect(buildUrlMatcher('[invalid')).to.be.null;
116
+ });
117
+ });
118
+ });
119
+ });