@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/.devin/config.local.json +4 -1
- package/.github/workflows/ci.yml +3 -0
- package/api/requestFeature.test.ts +36 -0
- package/api/requestFeature.ts +26 -4
- package/api/requestRetest.test.ts +18 -0
- package/api/requestRetest.ts +4 -0
- package/api/requestTest.test.ts +34 -0
- package/api/requestTest.ts +19 -1
- package/capability.md +2 -2
- package/docs/AI-TOOLS.md +3 -3
- package/docs/SERVICE.md +14 -1
- package/env.example +24 -0
- package/index.test.ts +51 -0
- package/index.ts +49 -20
- package/llms-full.txt +1 -1
- package/llms.txt +1 -1
- package/mcp.test.ts +41 -4
- package/mcp.ts +67 -17
- package/openapi.yaml +5 -5
- package/package.json +2 -1
- package/scripts/generate-openapi.ts +1 -1
- package/util.test.ts +272 -0
- package/util.ts +102 -39
- package/web/requestRetest/index.ts +9 -0
- package/web/requestRetest/requestRetest.test.ts +14 -0
- package/web/requestTest/index.ts +9 -0
- package/web/requestTest/requestTest.test.ts +16 -0
- package/web/tutorialAI/index.ts +25 -3
- package/web/tutorialAI/tutorialAI.test.ts +32 -0
- package/web/tutorialWeb/index.ts +25 -3
- package/web/tutorialWeb/tutorialWeb.test.ts +32 -0
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:
|
|
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
|
|
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:
|
|
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:
|
|
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": "
|
|
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
|
|
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).
|