@jrpool/kilotest 52.0.0 → 55.0.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/mcp.ts CHANGED
@@ -41,8 +41,28 @@ import {
41
41
 
42
42
  export const mcpPath = '/mcp';
43
43
 
44
+ // Shared statement of the Kilotest domain and data hierarchy, repeated in every tool
45
+ // description, in the server instructions, and in the getKilotestOverview tool description,
46
+ // because some MCP clients do not propagate server-level instructions or the server
47
+ // description to their models.
48
+ const sharedContext = 'Kilotest tests web pages for front-end quality (accessibility, usability, and standards conformity); results are organized as report, then issue, then violator element, then diagnosis.';
49
+
50
+ // Detailed overview of the Kilotest domain, data hierarchy, and tool workflow, returned by
51
+ // the getKilotestOverview tool and served as the docs://kilotest/overview resource.
52
+ const kilotestOverview = [
53
+ 'Kilotest tests web pages for front-end quality: accessibility, usability, and standards conformity.',
54
+ 'Kilotest integrates an ensemble of twelve independent rule engines to test a web page and stores the results as a structured report.',
55
+ 'Results are organized as a hierarchy. A report contains issues. An issue has violators: elements of the tested page reported as exhibiting the issue. A violator has diagnoses: explanations from rule engines of how the element exhibited the issue.',
56
+ 'Typical workflow: call the listReports tool to learn whether a report about the page already exists. If it does, drill down with the listIssues, listViolators, and listDiagnoses tools, or retrieve the entire report with the getReport tool. If no report exists, request a test with the requestTest tool. If the latest report is obsolete, request a retest with the requestRetest tool.',
57
+ 'More documentation is available at https://kilotest.com/llms.txt and https://kilotest.com/llms-full.txt.'
58
+ ].join('\n\n');
59
+
44
60
  // FUNCTIONS
45
61
 
62
+ // Returns a tool description consisting of the tool-specific summary followed by the shared
63
+ // Kilotest domain context.
64
+ const toolDoc = (summary: string): string => `${summary} ${sharedContext}`;
65
+
46
66
  // Creates and returns an McpServer with Kilotest tools registered.
47
67
  export const createMCPServer = (): McpServer => {
48
68
  const server = new McpServer({
@@ -51,16 +71,34 @@ export const createMCPServer = (): McpServer => {
51
71
  description: 'Tools that test web pages for front-end quality (accessibility, usability, and standards conformity) and make test results available'
52
72
  },
53
73
  {
54
- instructions: 'Use the listReports tool to start. If it shows that there is a report available about the page you want facts about, drill down with the listIssues, listViolators, and listDiagnoses tools. If not, use the requestTest tool to request that the page be tested. If the latest report about the page is obsolete, use the requestRetest tool to request that the page be retested.'
74
+ instructions: `${sharedContext} Use the listReports tool to start. If it shows that there is a report available about the page you want facts about, drill down with the listIssues, listViolators, and listDiagnoses tools. If not, use the requestTest tool to request that the page be tested. If the latest report about the page is obsolete, use the requestRetest tool to request that the page be retested. For detailed documentation, call the getKilotestOverview tool.`
55
75
  });
76
+ server.registerTool(
77
+ 'getKilotestOverview',
78
+ {
79
+ description: toolDoc('Explain what Kilotest is, what its tools do, and how its results are structured.'),
80
+ inputSchema: {},
81
+ annotations: {
82
+ title: toolDoc('Explain what Kilotest is, what its tools do, and how its results are structured.'),
83
+ readOnlyHint: true,
84
+ idempotentHint: true,
85
+ destructiveHint: false,
86
+ openWorldHint: false
87
+ }
88
+ },
89
+ async () => {
90
+ await recordMetric('mcpToolCalls', 'getKilotestOverview');
91
+ return {content: [{type: 'text', text: kilotestOverview}]};
92
+ }
93
+ );
56
94
  server.registerTool(
57
95
  'listReports',
58
96
  {
59
- description: 'Provide basics about all available reports.',
97
+ description: toolDoc('Provide basics about all available reports.'),
60
98
  inputSchema: {},
61
99
  outputSchema: listReportsResponseSchema,
62
100
  annotations: {
63
- title: 'Provide basics about all available reports.',
101
+ title: toolDoc('Provide basics about all available reports.'),
64
102
  readOnlyHint: true,
65
103
  idempotentHint: true,
66
104
  destructiveHint: false,
@@ -76,11 +114,11 @@ export const createMCPServer = (): McpServer => {
76
114
  server.registerTool(
77
115
  'listIssues',
78
116
  {
79
- description: 'Provide details about one report, including basics about the issues reported in it.',
117
+ description: toolDoc('Provide details about one report, including basics about the issues reported in it.'),
80
118
  inputSchema: listIssuesSchema,
81
119
  outputSchema: listIssuesResponseSchema,
82
120
  annotations: {
83
- title: 'Provide details about one report, including basics about the issues reported in it.',
121
+ title: toolDoc('Provide details about one report, including basics about the issues reported in it.'),
84
122
  readOnlyHint: true,
85
123
  idempotentHint: true,
86
124
  destructiveHint: false,
@@ -96,11 +134,11 @@ export const createMCPServer = (): McpServer => {
96
134
  server.registerTool(
97
135
  'listViolators',
98
136
  {
99
- description: 'Provide details about one issue in one report, including basics about the elements of the tested page that were reported as exhibiting the issue.',
137
+ description: toolDoc('Provide details about one issue in one report, including basics about the elements of the tested page that were reported as exhibiting the issue.'),
100
138
  inputSchema: listViolatorsSchema,
101
139
  outputSchema: listViolatorsResponseSchema,
102
140
  annotations: {
103
- title: 'Provide details about one issue in one report, including basics about the elements of the tested page that were reported as exhibiting the issue.',
141
+ title: toolDoc('Provide details about one issue in one report, including basics about the elements of the tested page that were reported as exhibiting the issue.'),
104
142
  readOnlyHint: true,
105
143
  idempotentHint: true,
106
144
  destructiveHint: false,
@@ -116,11 +154,11 @@ export const createMCPServer = (): McpServer => {
116
154
  server.registerTool(
117
155
  'listDiagnoses',
118
156
  {
119
- description: 'Provide details about one element reported as exhibiting one issue in one report, including the diagnoses provided by rule engines about how the element exhibited the issue.',
157
+ description: toolDoc('Provide details about one element reported as exhibiting one issue in one report, including the diagnoses provided by rule engines about how the element exhibited the issue.'),
120
158
  inputSchema: listDiagnosesSchema,
121
159
  outputSchema: listDiagnosesResponseSchema,
122
160
  annotations: {
123
- title: 'Provide details about one element reported as exhibiting one issue in one report, including the diagnoses provided by rule engines about how the element exhibited the issue.',
161
+ title: toolDoc('Provide details about one element reported as exhibiting one issue in one report, including the diagnoses provided by rule engines about how the element exhibited the issue.'),
124
162
  readOnlyHint: true,
125
163
  idempotentHint: true,
126
164
  destructiveHint: false,
@@ -136,11 +174,11 @@ export const createMCPServer = (): McpServer => {
136
174
  server.registerTool(
137
175
  'getReport',
138
176
  {
139
- description: 'Get one full report in JSON.',
177
+ description: toolDoc('Get one full report in JSON.'),
140
178
  inputSchema: getReportSchema,
141
179
  outputSchema: getReportResponseSchema,
142
180
  annotations: {
143
- title: 'Get one full report in JSON.',
181
+ title: toolDoc('Get one full report in JSON.'),
144
182
  readOnlyHint: true,
145
183
  idempotentHint: true,
146
184
  destructiveHint: false,
@@ -156,11 +194,11 @@ export const createMCPServer = (): McpServer => {
156
194
  server.registerTool(
157
195
  'requestTest',
158
196
  {
159
- description: 'Process my request to test a page about which no report is available yet.',
197
+ description: toolDoc('Process my request to test a page about which no report is available yet.'),
160
198
  inputSchema: requestTestSchema,
161
199
  outputSchema: requestTestResponseSchema,
162
200
  annotations: {
163
- title: 'Process my request to test a page about which no report is available yet.',
201
+ title: toolDoc('Process my request to test a page about which no report is available yet.'),
164
202
  readOnlyHint: false,
165
203
  idempotentHint: false,
166
204
  destructiveHint: false,
@@ -176,11 +214,11 @@ export const createMCPServer = (): McpServer => {
176
214
  server.registerTool(
177
215
  'requestRetest',
178
216
  {
179
- description: 'Process my request to retest a page about which a report is available.',
217
+ description: toolDoc('Process my request to retest a page about which a report is available.'),
180
218
  inputSchema: requestRetestSchema,
181
219
  outputSchema: requestRetestResponseSchema,
182
220
  annotations: {
183
- title: 'Process my request to retest a page about which a report is available.',
221
+ title: toolDoc('Process my request to retest a page about which a report is available.'),
184
222
  readOnlyHint: false,
185
223
  idempotentHint: false,
186
224
  destructiveHint: false,
@@ -196,11 +234,11 @@ export const createMCPServer = (): McpServer => {
196
234
  server.registerTool(
197
235
  'requestFeature',
198
236
  {
199
- description: 'Process my request to add or improve a feature.',
237
+ description: toolDoc('Process my request to add or improve a feature.'),
200
238
  inputSchema: requestFeatureSchema,
201
239
  outputSchema: requestFeatureResponseSchema,
202
240
  annotations: {
203
- title: 'Process my request to add or improve a feature.',
241
+ title: toolDoc('Process my request to add or improve a feature.'),
204
242
  readOnlyHint: false,
205
243
  idempotentHint: false,
206
244
  destructiveHint: false,
@@ -213,6 +251,18 @@ export const createMCPServer = (): McpServer => {
213
251
  return {content: [{type: 'text', text: JSON.stringify(result)}], structuredContent: result};
214
252
  }
215
253
  );
254
+ server.registerResource(
255
+ 'kilotest-overview',
256
+ 'docs://kilotest/overview',
257
+ {
258
+ title: 'Kilotest overview',
259
+ description: `What Kilotest is and how its tools and results are organized. ${sharedContext}`,
260
+ mimeType: 'text/plain'
261
+ },
262
+ async () => ({
263
+ contents: [{uri: 'docs://kilotest/overview', mimeType: 'text/plain', text: kilotestOverview}]
264
+ })
265
+ );
216
266
  return server;
217
267
  };
218
268
  // Handles an MCP request.
package/openapi.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  openapi: 3.1.0
2
2
  info:
3
3
  title: Kilotest API
4
- description: 'Kilotest is an ensemble testing and reporting service for front-end web quality. It uses an ensemble of 12 rule engines to test public web pages for front-end quality (accessibility, usability, and standards conformity). Kilotest acts as a web server for human users, an MCP server for AI platforms, and an API for programmatic access. With this API you can request that a page be tested or retested, discover available reports, incrementally retrieve facts from a report: from what reports are available, to what issues were reported in one report, to what elements violated one issue, to what diagnoses rule engines gave for one violation.'
4
+ description: 'Kilotest is an ensemble testing and reporting service for front-end web quality. It uses an ensemble of 12 rule engines to test web pages for front-end quality (accessibility, usability, and standards conformity). Kilotest acts as a web server for human users, an MCP server for AI platforms, and an API for programmatic access. With this API you can request that a page be tested or retested, discover available reports, incrementally retrieve facts from a report: from what reports are available, to what issues were reported in one report, to what elements violated one issue, to what diagnoses rule engines gave for one violation.'
5
5
  version: 2.1.0
6
6
  license:
7
7
  name: MIT
@@ -822,7 +822,7 @@ paths:
822
822
  properties:
823
823
  description:
824
824
  type: string
825
- description: 10- to 100-character description of the page conforming to the naming convention used in the listReports output
825
+ description: 1- to 100-character description of the page conforming to the naming convention used in the listReports output
826
826
  URL:
827
827
  type: string
828
828
  description: 12- to 300-character URL of the page, including the https:// scheme and any query
@@ -860,7 +860,7 @@ paths:
860
860
  properties:
861
861
  description:
862
862
  type: string
863
- description: 10- to 100-character description of the page conforming to the naming convention used in the listReports output
863
+ description: 1- to 100-character description of the page conforming to the naming convention used in the listReports output
864
864
  URL:
865
865
  type: string
866
866
  description: 12- to 300-character URL of the page, including the https:// scheme and any query
@@ -1099,7 +1099,7 @@ paths:
1099
1099
  properties:
1100
1100
  feature:
1101
1101
  type: string
1102
- description: description of requested feature improvement or new feature
1102
+ description: 20- to 1000-character description of requested feature improvement or new feature
1103
1103
  required:
1104
1104
  - feature
1105
1105
  responses:
@@ -1129,7 +1129,7 @@ paths:
1129
1129
  properties:
1130
1130
  feature:
1131
1131
  type: string
1132
- description: description of requested feature improvement or new feature
1132
+ description: 20- to 1000-character description of requested feature improvement or new feature
1133
1133
  required:
1134
1134
  - feature
1135
1135
  additionalProperties: false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jrpool/kilotest",
3
- "version": "52.0.0",
3
+ "version": "55.0.0",
4
4
  "description": "Ensemble testing for front-end web quality",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -38,6 +38,7 @@
38
38
  "dependencies": {
39
39
  "@modelcontextprotocol/sdk": "*",
40
40
  "dotenv": "*",
41
+ "ipaddr.js": "*",
41
42
  "testaro-issues": "*",
42
43
  "zod": "*"
43
44
  },
@@ -58,7 +58,7 @@ const generate = () => {
58
58
  openapi: '3.1.0',
59
59
  info: {
60
60
  title: 'Kilotest API',
61
- description: 'Kilotest is an ensemble testing and reporting service for front-end web quality. It uses an ensemble of 12 rule engines to test public web pages for front-end quality (accessibility, usability, and standards conformity). Kilotest acts as a web server for human users, an MCP server for AI platforms, and an API for programmatic access. With this API you can request that a page be tested or retested, discover available reports, incrementally retrieve facts from a report: from what reports are available, to what issues were reported in one report, to what elements violated one issue, to what diagnoses rule engines gave for one violation.',
61
+ description: 'Kilotest is an ensemble testing and reporting service for front-end web quality. It uses an ensemble of 12 rule engines to test web pages for front-end quality (accessibility, usability, and standards conformity). Kilotest acts as a web server for human users, an MCP server for AI platforms, and an API for programmatic access. With this API you can request that a page be tested or retested, discover available reports, incrementally retrieve facts from a report: from what reports are available, to what issues were reported in one report, to what elements violated one issue, to what diagnoses rule engines gave for one violation.',
62
62
  version,
63
63
  license: {
64
64
  name: license,
package/util.test.ts CHANGED
@@ -26,12 +26,14 @@ import {
26
26
  checkLength,
27
27
  createLock,
28
28
  dbPath,
29
+ describeMax,
29
30
  getAgoDays,
30
31
  getAgoString,
31
32
  getCountString,
32
33
  getDateString,
33
34
  getDateTime,
34
35
  getEngineNamesString,
36
+ getEnvMax,
35
37
  getIssue,
36
38
  errorMessage,
37
39
  getJSON,
@@ -52,6 +54,7 @@ import {
52
54
  getWeightName,
53
55
  hiddenReportsPath,
54
56
  htmlSafe,
57
+ isAllowedRedirectTarget,
55
58
  isAllowedReport,
56
59
  isAllowedTarget,
57
60
  isJobID,
@@ -268,6 +271,46 @@ test('getDateTime returns null for an invalid time stamp', () => {
268
271
  assert.equal(getDateTime('invalid'), null);
269
272
  });
270
273
 
274
+ test('getEnvMax returns the default when the variable is unset', () => {
275
+ delete process.env.TEST_ENV_MAX_VAR;
276
+ assert.equal(getEnvMax('TEST_ENV_MAX_VAR', 20), 20);
277
+ });
278
+
279
+ test('getEnvMax returns the default when the variable is empty', () => {
280
+ process.env.TEST_ENV_MAX_VAR = '';
281
+ assert.equal(getEnvMax('TEST_ENV_MAX_VAR', 20), 20);
282
+ delete process.env.TEST_ENV_MAX_VAR;
283
+ });
284
+
285
+ test('getEnvMax returns the configured value when it is a positive integer', () => {
286
+ process.env.TEST_ENV_MAX_VAR = '50';
287
+ assert.equal(getEnvMax('TEST_ENV_MAX_VAR', 20), 50);
288
+ delete process.env.TEST_ENV_MAX_VAR;
289
+ });
290
+
291
+ test('getEnvMax returns the default when the variable is not an integer', () => {
292
+ process.env.TEST_ENV_MAX_VAR = 'not-a-number';
293
+ assert.equal(getEnvMax('TEST_ENV_MAX_VAR', 20), 20);
294
+ delete process.env.TEST_ENV_MAX_VAR;
295
+ });
296
+
297
+ test('getEnvMax returns 0 when the variable is explicitly 0, meaning no limit', () => {
298
+ process.env.TEST_ENV_MAX_VAR = '0';
299
+ assert.equal(getEnvMax('TEST_ENV_MAX_VAR', 20), 0);
300
+ delete process.env.TEST_ENV_MAX_VAR;
301
+ });
302
+
303
+ test('getEnvMax returns the default when the variable is negative', () => {
304
+ process.env.TEST_ENV_MAX_VAR = '-5';
305
+ assert.equal(getEnvMax('TEST_ENV_MAX_VAR', 20), 20);
306
+ delete process.env.TEST_ENV_MAX_VAR;
307
+ });
308
+
309
+ test('describeMax returns "no limit" for a cap of 0 and the number otherwise', () => {
310
+ assert.equal(describeMax(0), 'no limit');
311
+ assert.equal(describeMax(20), '20');
312
+ });
313
+
271
314
  test('getIssue returns an issue ID for a known engine and rule', () => {
272
315
  const issueID = getIssue('axe', 'link-name');
273
316
  assert.ok(typeof issueID === 'string' || issueID === null);
@@ -451,6 +494,66 @@ test('isAllowedTarget returns true for a URL resolving to a public IPv6 address'
451
494
  assert.equal(await isAllowedTarget('https://example.com/page', fakeLookup('2606:2800:220:1:248:1893:25c8:1946')), true);
452
495
  });
453
496
 
497
+ test('isAllowedTarget returns false for a URL resolving to a TEST-NET-1 address', async () => {
498
+ delete process.env.ALLOW_INTERNAL_TARGETS;
499
+ assert.equal(await isAllowedTarget('https://example.com/page', fakeLookup('192.0.2.1')), false);
500
+ });
501
+
502
+ test('isAllowedTarget returns false for a URL resolving to a TEST-NET-2 address', async () => {
503
+ delete process.env.ALLOW_INTERNAL_TARGETS;
504
+ assert.equal(await isAllowedTarget('https://example.com/page', fakeLookup('198.51.100.1')), false);
505
+ });
506
+
507
+ test('isAllowedTarget returns false for a URL resolving to a TEST-NET-3 address', async () => {
508
+ delete process.env.ALLOW_INTERNAL_TARGETS;
509
+ assert.equal(await isAllowedTarget('https://example.com/page', fakeLookup('203.0.113.1')), false);
510
+ });
511
+
512
+ test('isAllowedTarget returns false for a URL resolving to a benchmarking (198.18/15) address', async () => {
513
+ delete process.env.ALLOW_INTERNAL_TARGETS;
514
+ assert.equal(await isAllowedTarget('https://example.com/page', fakeLookup('198.18.0.1')), false);
515
+ });
516
+
517
+ test('isAllowedTarget returns false for a URL resolving to a reserved 240/4 address', async () => {
518
+ delete process.env.ALLOW_INTERNAL_TARGETS;
519
+ assert.equal(await isAllowedTarget('https://example.com/page', fakeLookup('240.0.0.1')), false);
520
+ });
521
+
522
+ test('isAllowedTarget returns false for a URL resolving to the broadcast address', async () => {
523
+ delete process.env.ALLOW_INTERNAL_TARGETS;
524
+ assert.equal(await isAllowedTarget('https://example.com/page', fakeLookup('255.255.255.255')), false);
525
+ });
526
+
527
+ test('isAllowedTarget returns false for a URL resolving to a multicast address', async () => {
528
+ delete process.env.ALLOW_INTERNAL_TARGETS;
529
+ assert.equal(await isAllowedTarget('https://example.com/page', fakeLookup('224.0.0.1')), false);
530
+ });
531
+
532
+ test('isAllowedTarget returns false for a URL resolving to a deprecated IPv6 site-local address', async () => {
533
+ delete process.env.ALLOW_INTERNAL_TARGETS;
534
+ assert.equal(await isAllowedTarget('https://example.com/page', fakeLookup('fec0::1')), false);
535
+ });
536
+
537
+ test('isAllowedTarget returns false for a URL resolving to an IPv6 multicast address', async () => {
538
+ delete process.env.ALLOW_INTERNAL_TARGETS;
539
+ assert.equal(await isAllowedTarget('https://example.com/page', fakeLookup('ff02::1')), false);
540
+ });
541
+
542
+ test('isAllowedTarget returns false for a URL resolving to a 6to4 (2002::/16) address', async () => {
543
+ delete process.env.ALLOW_INTERNAL_TARGETS;
544
+ assert.equal(await isAllowedTarget('https://example.com/page', fakeLookup('2002::1')), false);
545
+ });
546
+
547
+ test('isAllowedTarget returns false for a URL resolving to a Teredo (2001::/32) address', async () => {
548
+ delete process.env.ALLOW_INTERNAL_TARGETS;
549
+ assert.equal(await isAllowedTarget('https://example.com/page', fakeLookup('2001::1')), false);
550
+ });
551
+
552
+ test('isAllowedTarget returns false for a URL resolving to an NAT64 (64:ff9b::/96) address', async () => {
553
+ delete process.env.ALLOW_INTERNAL_TARGETS;
554
+ assert.equal(await isAllowedTarget('https://example.com/page', fakeLookup('64:ff9b::10.0.0.5')), false);
555
+ });
556
+
454
557
  test('isAllowedTarget returns false for a URL resolving to an address family net.isIP does not recognize', async () => {
455
558
  delete process.env.ALLOW_INTERNAL_TARGETS;
456
559
  assert.equal(await isAllowedTarget('https://example.com/page', async () => [{address: 'not-an-ip', family: 0}]), false);
@@ -482,6 +585,118 @@ test('isAllowedTarget returns true for a private-address target when ALLOW_INTER
482
585
  assert.equal(await isAllowedTarget('https://192.168.1.1/page'), true);
483
586
  });
484
587
 
588
+ // A fake fetch that returns a response whose url reflects any redirect,
589
+ // so isAllowedRedirectTarget tests need no real network access.
590
+ const fakeFetch = (finalURL: string) => async () => ({url: finalURL}) as Response;
591
+
592
+ test('isAllowedRedirectTarget returns true when the final URL resolves to a public address', async () => {
593
+ delete process.env.ALLOW_INTERNAL_TARGETS;
594
+ assert.equal(
595
+ await isAllowedRedirectTarget('https://example.com/page', fakeFetch('https://example.com/page'), fakeLookup('93.184.216.34')),
596
+ true
597
+ );
598
+ });
599
+
600
+ test('isAllowedRedirectTarget returns false when a redirect leads to a private address', async () => {
601
+ delete process.env.ALLOW_INTERNAL_TARGETS;
602
+ assert.equal(
603
+ await isAllowedRedirectTarget('https://example.com/page', fakeFetch('https://10.0.0.5/page'), fakeLookup('93.184.216.34')),
604
+ false
605
+ );
606
+ });
607
+
608
+ test('isAllowedRedirectTarget returns false when the fetch throws', async () => {
609
+ delete process.env.ALLOW_INTERNAL_TARGETS;
610
+ const fetchImpl = async () => { throw new Error('fetch failed'); };
611
+ assert.equal(await isAllowedRedirectTarget('https://example.com/page', fetchImpl), false);
612
+ });
613
+
614
+ test('isAllowedRedirectTarget returns true for any URL when ALLOW_INTERNAL_TARGETS is true, without fetching', async () => {
615
+ process.env.ALLOW_INTERNAL_TARGETS = 'true';
616
+ const fetchImpl = async () => { throw new Error('fetch should not be called'); };
617
+ assert.equal(await isAllowedRedirectTarget('https://192.168.1.1/page', fetchImpl), true);
618
+ });
619
+
620
+ test('isAllowedRedirectTarget returns false when a redirect leads to a TEST-NET-1 address', async () => {
621
+ delete process.env.ALLOW_INTERNAL_TARGETS;
622
+ assert.equal(
623
+ await isAllowedRedirectTarget('https://example.com/page', fakeFetch('https://192.0.2.1/page'), fakeLookup('93.184.216.34')),
624
+ false
625
+ );
626
+ });
627
+
628
+ test('isAllowedRedirectTarget returns false when a redirect leads to a TEST-NET-2 address', async () => {
629
+ delete process.env.ALLOW_INTERNAL_TARGETS;
630
+ assert.equal(
631
+ await isAllowedRedirectTarget('https://example.com/page', fakeFetch('https://198.51.100.1/page'), fakeLookup('93.184.216.34')),
632
+ false
633
+ );
634
+ });
635
+
636
+ test('isAllowedRedirectTarget returns false when a redirect leads to a TEST-NET-3 address', async () => {
637
+ delete process.env.ALLOW_INTERNAL_TARGETS;
638
+ assert.equal(
639
+ await isAllowedRedirectTarget('https://example.com/page', fakeFetch('https://203.0.113.1/page'), fakeLookup('93.184.216.34')),
640
+ false
641
+ );
642
+ });
643
+
644
+ test('isAllowedRedirectTarget returns false when a redirect leads to a benchmarking (198.18/15) address', async () => {
645
+ delete process.env.ALLOW_INTERNAL_TARGETS;
646
+ assert.equal(
647
+ await isAllowedRedirectTarget('https://example.com/page', fakeFetch('https://198.18.0.1/page'), fakeLookup('93.184.216.34')),
648
+ false
649
+ );
650
+ });
651
+
652
+ test('isAllowedRedirectTarget returns false when a redirect leads to a reserved 240/4 address', async () => {
653
+ delete process.env.ALLOW_INTERNAL_TARGETS;
654
+ assert.equal(
655
+ await isAllowedRedirectTarget('https://example.com/page', fakeFetch('https://240.0.0.1/page'), fakeLookup('93.184.216.34')),
656
+ false
657
+ );
658
+ });
659
+
660
+ test('isAllowedRedirectTarget returns false when a redirect leads to the broadcast address', async () => {
661
+ delete process.env.ALLOW_INTERNAL_TARGETS;
662
+ assert.equal(
663
+ await isAllowedRedirectTarget('https://example.com/page', fakeFetch('https://255.255.255.255/page'), fakeLookup('93.184.216.34')),
664
+ false
665
+ );
666
+ });
667
+
668
+ test('isAllowedRedirectTarget returns false when a redirect leads to a multicast address', async () => {
669
+ delete process.env.ALLOW_INTERNAL_TARGETS;
670
+ assert.equal(
671
+ await isAllowedRedirectTarget('https://example.com/page', fakeFetch('https://224.0.0.1/page'), fakeLookup('93.184.216.34')),
672
+ false
673
+ );
674
+ });
675
+
676
+ test('isAllowedRedirectTarget returns false when a redirect leads to a deprecated IPv6 site-local address', async () => {
677
+ delete process.env.ALLOW_INTERNAL_TARGETS;
678
+ assert.equal(
679
+ await isAllowedRedirectTarget('https://example.com/page', fakeFetch('https://[fec0::1]/page'), fakeLookup('93.184.216.34')),
680
+ false
681
+ );
682
+ });
683
+
684
+ test('isAllowedRedirectTarget returns false when a redirect leads to a 6to4 (2002::/16) address', async () => {
685
+ delete process.env.ALLOW_INTERNAL_TARGETS;
686
+ assert.equal(
687
+ await isAllowedRedirectTarget('https://example.com/page', fakeFetch('https://[2002::1]/page'), fakeLookup('93.184.216.34')),
688
+ false
689
+ );
690
+ });
691
+
692
+ test('isAllowedRedirectTarget returns false when a redirect leads to a Teredo (2001::/32) address', async () => {
693
+ delete process.env.ALLOW_INTERNAL_TARGETS;
694
+ assert.equal(
695
+ await isAllowedRedirectTarget('https://example.com/page', fakeFetch('https://[2001::1]/page'), fakeLookup('93.184.216.34')),
696
+ false
697
+ );
698
+ });
699
+
485
700
  test('isAllowedReport returns true when every act has a public actualURL', async () => {
486
701
  delete process.env.ALLOW_INTERNAL_TARGETS;
487
702
  const report = {
@@ -681,6 +896,63 @@ test('processTestRequest returns a retest error when a report already exists for
681
896
  assert.equal(result, 'retest');
682
897
  });
683
898
 
899
+ test('processTestRequest returns a queueFull error once the pending-request queue is full', {timeout: 500}, async () => {
900
+ // Fill testRequests.json with 20 pending requests, all for the same URL, so the total
901
+ // pending count (summed across all URLs' request arrays) reaches the cap directly,
902
+ // without needing 20 distinct URLs.
903
+ const filler = Array.from({length: 20}, (_, i) => ({
904
+ timeStamp: getNowStamp(), description: `Filler Page ${i}`, reason: 'because filler'
905
+ }));
906
+ await fs.writeFile(testRequestsPath(), getJSON({'https://example.com/filler': filler}));
907
+ // A further, distinct request is rejected as queueFull.
908
+ const {result} = await processTestRequest(
909
+ 'because accessibility', {description: 'One Too Many Page', url: 'https://example.com/one-too-many'}
910
+ );
911
+ assert.equal(result, 'queueFull');
912
+ // Clean up testRequests.json.
913
+ await fs.writeFile(testRequestsPath(), '{}\n');
914
+ });
915
+
916
+ test('processTestRequest honors TEST_REQUEST_QUEUE_MAX when the cap is reached below the default', {timeout: 500}, async () => {
917
+ // With the cap lowered to 2, a single pending request already fills the queue.
918
+ process.env.TEST_REQUEST_QUEUE_MAX = '2';
919
+ try {
920
+ await fs.writeFile(testRequestsPath(), getJSON({
921
+ 'https://example.com/filler': [
922
+ {timeStamp: getNowStamp(), description: 'Filler Page A', reason: 'because filler'},
923
+ {timeStamp: getNowStamp(), description: 'Filler Page B', reason: 'because filler'}
924
+ ]
925
+ }));
926
+ const {result} = await processTestRequest(
927
+ 'because accessibility', {description: 'One Too Many Page', url: 'https://example.com/one-too-many'}
928
+ );
929
+ assert.equal(result, 'queueFull');
930
+ }
931
+ finally {
932
+ delete process.env.TEST_REQUEST_QUEUE_MAX;
933
+ await fs.writeFile(testRequestsPath(), '{}\n');
934
+ }
935
+ });
936
+
937
+ test('processTestRequest never returns queueFull when TEST_REQUEST_QUEUE_MAX is 0 (no limit)', {timeout: 500}, async () => {
938
+ // With the cap set to 0, a queue already past the default size is still not full.
939
+ process.env.TEST_REQUEST_QUEUE_MAX = '0';
940
+ try {
941
+ const filler = Array.from({length: 25}, (_, i) => ({
942
+ timeStamp: getNowStamp(), description: `Filler Page ${i}`, reason: 'because filler'
943
+ }));
944
+ await fs.writeFile(testRequestsPath(), getJSON({'https://example.com/filler': filler}));
945
+ const {result} = await processTestRequest(
946
+ 'because accessibility', {description: 'One More Page', url: 'https://example.com/one-more'}
947
+ );
948
+ assert.equal(result, 'ok');
949
+ }
950
+ finally {
951
+ delete process.env.TEST_REQUEST_QUEUE_MAX;
952
+ await fs.writeFile(testRequestsPath(), '{}\n');
953
+ }
954
+ });
955
+
684
956
  test('annotateReportObject annotates a report object in place without reading or writing a file', async () => {
685
957
  // A report object that was never written to disk (e.g. one just received from a worker,
686
958
  // as in index.ts's worker/report handler, which annotates before the first write).