@jrpool/kilotest 46.0.6 → 46.0.8

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.
@@ -0,0 +1,26 @@
1
+ name: Smoke Test
2
+
3
+ on:
4
+ pull_request:
5
+ branches: [main]
6
+
7
+ jobs:
8
+ smoke:
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ contents: read
12
+
13
+ steps:
14
+ - name: Checkout code
15
+ uses: actions/checkout@v4
16
+
17
+ - name: Set Node.js version
18
+ uses: actions/setup-node@v4
19
+ with:
20
+ node-version: '>=22.12.0'
21
+
22
+ - name: Install dependencies
23
+ run: npm ci
24
+
25
+ - name: Run smoke test against deployed server
26
+ run: node smokeTest.js
@@ -0,0 +1,82 @@
1
+ /*
2
+ apiIntegration.test.js
3
+ Minimal HTTP integration tests verifying routing, body parsing, and JSON serialization, using the fixture corpus and a temporary in-process server.
4
+ */
5
+
6
+ // IMPORTS
7
+
8
+ const {test, before, after} = require('node:test');
9
+ const assert = require('node:assert/strict');
10
+ const path = require('node:path');
11
+ const http = require('node:http');
12
+ const {requestHandler} = require('./index');
13
+
14
+ // CONSTANTS
15
+
16
+ const port = 3998;
17
+
18
+ // SETUP AND TEARDOWN
19
+
20
+ const savedDBDir = process.env.DB_DIR;
21
+ let server;
22
+
23
+ before(async () => {
24
+ process.env.DB_DIR = path.join(__dirname, 'test', 'fixtures', 'db');
25
+ server = http.createServer(requestHandler);
26
+ await new Promise(resolve => server.listen(port, resolve));
27
+ });
28
+
29
+ after(async () => {
30
+ if (savedDBDir !== undefined) {
31
+ process.env.DB_DIR = savedDBDir;
32
+ }
33
+ else {
34
+ delete process.env.DB_DIR;
35
+ }
36
+ await new Promise(resolve => server.close(() => resolve()));
37
+ });
38
+
39
+ // HELPERS
40
+
41
+ // Sends an HTTP request and returns the parsed JSON response body.
42
+ const request = (method, requestPath, body = null) => new Promise((resolve, reject) => {
43
+ const options = {method, host: 'localhost', port, path: requestPath};
44
+ if (body) {
45
+ const bodyJSON = JSON.stringify(body);
46
+ options.headers = {
47
+ 'content-type': 'application/json; charset=utf-8',
48
+ 'content-length': Buffer.byteLength(bodyJSON)
49
+ };
50
+ }
51
+ const req = http.request(options, response => {
52
+ const chunks = [];
53
+ response.on('data', chunk => chunks.push(chunk));
54
+ response.on('end', () => {
55
+ const bodyString = chunks.join('');
56
+ try {
57
+ resolve({statusCode: response.statusCode, body: JSON.parse(bodyString)});
58
+ }
59
+ catch {
60
+ resolve({statusCode: response.statusCode, body: bodyString});
61
+ }
62
+ });
63
+ });
64
+ req.on('error', reject);
65
+ req.end(body ? JSON.stringify(body) : '');
66
+ });
67
+
68
+ // TESTS
69
+
70
+ test('GET /api/listReports returns valid JSON through the HTTP routing layer', async () => {
71
+ const {body} = await request('GET', '/api/listReports');
72
+ assert.equal(body['tool name'], 'listReports');
73
+ const reports = body['response content']['basics about all available reports'];
74
+ assert.equal(reports.length, 6);
75
+ });
76
+
77
+ test('POST /api/requestFeature parses a JSON body and returns a JSON response through the HTTP routing layer', async () => {
78
+ const {body} = await request('POST', '/api/requestFeature', {feature: 'Add a dark mode toggle'});
79
+ const details = body['response content']['details about your request'];
80
+ assert.equal(details.error, undefined);
81
+ assert.equal(details.disposition, 'received and logged; manager notified');
82
+ });
package/docs/SERVICE.md CHANGED
@@ -203,7 +203,7 @@ kilotest.com {
203
203
  }
204
204
  @allowedPOST {
205
205
  method POST
206
- path /mcp /requestRetest.html/* /requestTest.html /recAction.html /reannotate.html /renewWCAG.html /api/* /tutorialComment.html
206
+ path /mcp /requestRetest.html/* /requestTest.html /recAction.html /reannotate.html /renewWCAG.html /api/* /tutorialComment.html /worker/job /worker/report
207
207
  }
208
208
  # Respond to OPTIONS requests.
209
209
  @allowedOPTIONS method OPTIONS
@@ -259,6 +259,30 @@ When a new version of the `kilotest` package has been published, the service can
259
259
  1. Update the Playwright browsers: `npx playwright install`
260
260
  1. Restart the service: `pm2 restart kilotest`
261
261
 
262
+ ## Branch protection
263
+
264
+ The `main` branch on GitHub is protected to prevent direct pushes and to require a passing smoke test before any pull request can merge. The settings are configured in the GitHub repository as a ruleset under Settings > Branches > Branch protection rules.
265
+
266
+ The required settings are:
267
+
268
+ 1. Require a pull request before merging.
269
+ 1. Require status checks to pass before merging.
270
+ 1. Add "Smoke Test" as a required status check.
271
+ 1. Require branches to be up to date before merging.
272
+ 1. Do not allow bypassing the above settings.
273
+
274
+ A copy of the ruleset is located at `docs/Smoke Test.json`.
275
+
276
+ The smoke-test check is defined in `.github/workflows/smoke-test.yml` and runs `smokeTest.js`, which sends HTTPS requests to all valid GET and POST paths on the deployed server and verifies that Caddy forwards each one to Kilotest rather than returning a bare 404.
277
+
278
+ When a pull request adds a new route to `index.js`, the Caddyfile at `/etc/caddy/Caddyfile` on the server must be updated to forward the new path before the pull request can merge. The workflow to follow is:
279
+
280
+ 1. Add the new route to the `routes` constant and the dispatch chain in `index.js`.
281
+ 1. Update the Caddyfile (`/etc/caddy/Caddyfile`) on the server to forward the new path.
282
+ 1. Open or update the pull request.
283
+ 1. The smoke test workflow runs and verifies that all paths, including the new one, are forwarded by Caddy.
284
+ 1. If the Caddyfile was not updated, the smoke test reports a bare 404 for the new path, the required status check fails, and the pull request cannot merge.
285
+
262
286
  ## Performance
263
287
 
264
288
  The Cloud Compute host, in initial testing, took about 2.5 as long to process an example job as an Apple M2 Pro MacBook Pro with 16GB of memory. After tuning, the ratio was reduced to about 1.7.
@@ -0,0 +1,54 @@
1
+ {
2
+ "id": 22391521,
3
+ "name": "Smoke Test",
4
+ "target": "branch",
5
+ "source_type": "Repository",
6
+ "source": "jrpool/kilotest",
7
+ "enforcement": "active",
8
+ "conditions": {
9
+ "ref_name": {
10
+ "exclude": [],
11
+ "include": [
12
+ "~DEFAULT_BRANCH"
13
+ ]
14
+ }
15
+ },
16
+ "rules": [
17
+ {
18
+ "type": "deletion"
19
+ },
20
+ {
21
+ "type": "non_fast_forward"
22
+ },
23
+ {
24
+ "type": "pull_request",
25
+ "parameters": {
26
+ "required_approving_review_count": 0,
27
+ "dismiss_stale_reviews_on_push": true,
28
+ "required_reviewers": [],
29
+ "require_code_owner_review": false,
30
+ "require_last_push_approval": false,
31
+ "required_review_thread_resolution": false,
32
+ "require_extra_approval_for_unattributed_changes": true,
33
+ "allowed_merge_methods": [
34
+ "merge",
35
+ "squash",
36
+ "rebase"
37
+ ]
38
+ }
39
+ },
40
+ {
41
+ "type": "required_status_checks",
42
+ "parameters": {
43
+ "strict_required_status_checks_policy": true,
44
+ "do_not_enforce_on_create": false,
45
+ "required_status_checks": [
46
+ {
47
+ "context": "Smoke Test"
48
+ }
49
+ ]
50
+ }
51
+ }
52
+ ],
53
+ "bypass_actors": []
54
+ }
package/index.js CHANGED
@@ -63,6 +63,54 @@ const answer = {
63
63
 
64
64
  // CONSTANTS
65
65
 
66
+ // Paths that the application is authorized to handle, by method, as glob-style patterns where * matches any sequence of characters.
67
+ const routes = exports.routes = {
68
+ GET: [
69
+ '/mcp',
70
+ '/',
71
+ '/index.html',
72
+ '/robots.txt',
73
+ '/openapi.yaml',
74
+ '/openapi.json',
75
+ '/swagger.yaml',
76
+ '/swagger.json',
77
+ '/api-docs',
78
+ '/llms.txt',
79
+ '/llms-full.txt',
80
+ '/sitemap.xml',
81
+ '/style.css',
82
+ '/fullReport.json/*',
83
+ '/api/*',
84
+ '/tutorial/images/*',
85
+ '/favicon.*',
86
+ '*.html*'
87
+ ],
88
+ POST: [
89
+ '/mcp',
90
+ '/requestTest.html',
91
+ '/requestRetest.html/*',
92
+ '/recAction.html',
93
+ '/reannotate.html',
94
+ '/renewWCAG.html',
95
+ '/worker/job',
96
+ '/worker/report',
97
+ '/api/*',
98
+ '/tutorialComment.html'
99
+ ]
100
+ };
101
+ // Returns whether a pathname matches a glob-style pattern.
102
+ const matchPath = (pattern, pathname) => {
103
+ const regex = new RegExp(
104
+ '^' + pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$'
105
+ );
106
+ return regex.test(pathname);
107
+ };
108
+ // Returns whether a pathname is authorized for a method.
109
+ const isPathAllowed = (method, pathname) => {
110
+ const patterns = routes[method] || [];
111
+ return patterns.some(pattern => matchPath(pattern, pathname));
112
+ };
113
+
66
114
  const protocol = process.env.PROTOCOL || 'http';
67
115
  const queuePath = () => path.join(jobsPath(), 'queue');
68
116
  const claimedPath = () => path.join(jobsPath(), 'claimed');
@@ -321,8 +369,12 @@ const requestHandler = async (request, response) => {
321
369
  const pathTail = pathname.split('/').slice(2).join('/');
322
370
  // If the request is a GET request:
323
371
  if (method === 'GET') {
372
+ // If the path is not authorized for GET requests:
373
+ if (!isPathAllowed('GET', pathname)) {
374
+ await serveError({message: `ERROR: Invalid GET request (${pathname})`}, response, true);
375
+ }
324
376
  // If it is for the model context protocol server:
325
- if (pathname === mcpPath) {
377
+ else if (pathname === mcpPath) {
326
378
  // Handle the MCP request.
327
379
  await handleMCP(request, response);
328
380
  }
@@ -554,8 +606,12 @@ const requestHandler = async (request, response) => {
554
606
  }
555
607
  // Otherwise, if the request is a POST request:
556
608
  else if (method === 'POST') {
609
+ // If the path is not authorized for POST requests:
610
+ if (!isPathAllowed('POST', pathname)) {
611
+ await serveError({message: 'ERROR: Invalid POST request'}, response, true);
612
+ }
557
613
  // If it is for the model context protocol server:
558
- if (pageName === 'mcp') {
614
+ else if (pageName === 'mcp') {
559
615
  await handleMCP(request, response);
560
616
  }
561
617
  // Otherwise, i.e. if it is not for the MCP server:
@@ -861,6 +917,10 @@ const requestHandler = async (request, response) => {
861
917
  }
862
918
  };
863
919
 
920
+ // EXPORTS
921
+
922
+ exports.requestHandler = requestHandler;
923
+
864
924
  // SERVER
865
925
 
866
926
  const serve = async (protocolModule, options) => {
@@ -879,23 +939,25 @@ const serve = async (protocolModule, options) => {
879
939
 
880
940
  // EXECUTION
881
941
 
882
- if (protocol === 'http') {
883
- console.log('Starting HTTP server');
884
- serve(http, {});
885
- }
886
- else if (protocol === 'https') {
887
- console.log('Starting HTTPS server');
888
- fs.readFile(process.env.KEY, 'utf8')
889
- .then(
890
- key => {
891
- fs.readFile(process.env.CERT, 'utf8')
892
- .then(
893
- cert => {
894
- serve(https, {key, cert});
895
- },
896
- error => console.log(error.message)
897
- );
898
- },
899
- error => console.log(error.message)
900
- );
942
+ if (require.main === module) {
943
+ if (protocol === 'http') {
944
+ console.log('Starting HTTP server');
945
+ serve(http, {});
946
+ }
947
+ else if (protocol === 'https') {
948
+ console.log('Starting HTTPS server');
949
+ fs.readFile(process.env.KEY, 'utf8')
950
+ .then(
951
+ key => {
952
+ fs.readFile(process.env.CERT, 'utf8')
953
+ .then(
954
+ cert => {
955
+ serve(https, {key, cert});
956
+ },
957
+ error => console.log(error.message)
958
+ );
959
+ },
960
+ error => console.log(error.message)
961
+ );
962
+ }
901
963
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jrpool/kilotest",
3
- "version": "46.0.6",
3
+ "version": "46.0.8",
4
4
  "description": "Ensemble testing for front-end web quality",
5
5
  "main": "index.js",
6
6
  "engines": {
@@ -10,7 +10,7 @@
10
10
  "start": "node index",
11
11
  "lint": "npx eslint && npx markdownlint-cli2",
12
12
  "test": "c8 node --test",
13
- "test:api": "node api/testSuite",
13
+ "test:smoke": "node smokeTest",
14
14
  "generate:openapi": "node scripts/generate-openapi"
15
15
  },
16
16
  "repository": {
@@ -49,6 +49,7 @@
49
49
  "globals": "*",
50
50
  "js-yaml": "*",
51
51
  "markdownlint-cli2": "*",
52
+ "node-html-parser": "*",
52
53
  "zod-openapi": "*"
53
54
  },
54
55
  "allowScripts": {
package/routes.test.js ADDED
@@ -0,0 +1,69 @@
1
+ /*
2
+ routes.test.js
3
+ Tests for the centralized routes table exported from index.js.
4
+ */
5
+
6
+ // IMPORTS
7
+
8
+ const {test} = require('node:test');
9
+ const assert = require('node:assert/strict');
10
+ const {routes} = require('./index');
11
+
12
+ // TESTS
13
+
14
+ test('routes has GET and POST arrays', () => {
15
+ assert.ok(Array.isArray(routes.GET));
16
+ assert.ok(Array.isArray(routes.POST));
17
+ assert.ok(routes.GET.length > 0);
18
+ assert.ok(routes.POST.length > 0);
19
+ });
20
+
21
+ test('routes GET includes all known GET paths', () => {
22
+ const required = [
23
+ '/mcp',
24
+ '/',
25
+ '/index.html',
26
+ '/robots.txt',
27
+ '/openapi.yaml',
28
+ '/openapi.json',
29
+ '/swagger.yaml',
30
+ '/swagger.json',
31
+ '/api-docs',
32
+ '/llms.txt',
33
+ '/llms-full.txt',
34
+ '/sitemap.xml',
35
+ '/style.css'
36
+ ];
37
+ for (const path of required) {
38
+ assert.ok(routes.GET.includes(path), `routes.GET missing ${path}`);
39
+ }
40
+ });
41
+
42
+ test('routes POST includes all known POST paths', () => {
43
+ const required = [
44
+ '/mcp',
45
+ '/requestTest.html',
46
+ '/recAction.html',
47
+ '/reannotate.html',
48
+ '/renewWCAG.html',
49
+ '/worker/job',
50
+ '/worker/report',
51
+ '/tutorialComment.html'
52
+ ];
53
+ for (const path of required) {
54
+ assert.ok(routes.POST.includes(path), `routes.POST missing ${path}`);
55
+ }
56
+ });
57
+
58
+ test('routes GET includes wildcard patterns for dynamic paths', () => {
59
+ assert.ok(routes.GET.some(p => p.includes('fullReport.json')));
60
+ assert.ok(routes.GET.some(p => p.includes('/api/')));
61
+ assert.ok(routes.GET.some(p => p.includes('tutorial/images')));
62
+ assert.ok(routes.GET.some(p => p.includes('favicon')));
63
+ assert.ok(routes.GET.some(p => p.includes('.html')));
64
+ });
65
+
66
+ test('routes POST includes wildcard patterns for dynamic paths', () => {
67
+ assert.ok(routes.POST.some(p => p.includes('requestRetest.html')));
68
+ assert.ok(routes.POST.some(p => p.includes('/api/')));
69
+ });
@@ -0,0 +1,77 @@
1
+ /*
2
+ routing.test.js
3
+ Integration tests for HTTP routing in index.js, verifying that POST-only API services reject GET requests.
4
+ */
5
+
6
+ // IMPORTS
7
+
8
+ const {test, before, after} = require('node:test');
9
+ const assert = require('node:assert/strict');
10
+ const path = require('node:path');
11
+ const http = require('node:http');
12
+ const {requestHandler} = require('./index');
13
+
14
+ // CONSTANTS
15
+
16
+ const port = 3999;
17
+
18
+ // SETUP AND TEARDOWN
19
+
20
+ const savedDBDir = process.env.DB_DIR;
21
+ let server;
22
+
23
+ before(async () => {
24
+ process.env.DB_DIR = path.join(__dirname, 'test', 'fixtures', 'db');
25
+ server = http.createServer(requestHandler);
26
+ await new Promise(resolve => server.listen(port, resolve));
27
+ });
28
+
29
+ after(async () => {
30
+ if (savedDBDir !== undefined) {
31
+ process.env.DB_DIR = savedDBDir;
32
+ }
33
+ else {
34
+ delete process.env.DB_DIR;
35
+ }
36
+ await new Promise(resolve => server.close(() => resolve()));
37
+ });
38
+
39
+ // HELPERS
40
+
41
+ // Sends a GET request and returns the response body as a string.
42
+ const get = requestPath => new Promise((resolve, reject) => {
43
+ http.request({method: 'GET', host: 'localhost', port, path: requestPath}, response => {
44
+ const chunks = [];
45
+ response.on('data', chunk => chunks.push(chunk));
46
+ response.on('end', () => {
47
+ resolve({statusCode: response.statusCode, body: chunks.join('')});
48
+ });
49
+ })
50
+ .on('error', reject)
51
+ .end();
52
+ });
53
+
54
+ // TESTS
55
+
56
+ test('GET /api/requestTest returns an error, not a test-request response', async () => {
57
+ const result = await get('/api/requestTest');
58
+ assert.ok(result.body.includes('Invalid service request'));
59
+ });
60
+
61
+ test('GET /api/requestRetest returns an error, not a retest-request response', async () => {
62
+ const result = await get('/api/requestRetest/260101T0001/ct');
63
+ assert.ok(result.body.includes('Invalid service request'));
64
+ });
65
+
66
+ test('GET /api/requestFeature returns an error, not a feature-request response', async () => {
67
+ const result = await get('/api/requestFeature');
68
+ assert.ok(result.body.includes('Invalid service request'));
69
+ });
70
+
71
+ test('GET /api/listReports returns a valid JSON response', async () => {
72
+ const result = await get('/api/listReports');
73
+ const body = JSON.parse(result.body);
74
+ assert.equal(body['tool name'], 'listReports');
75
+ const reports = body['response content']['basics about all available reports'];
76
+ assert.equal(reports.length, 6);
77
+ });
package/smokeTest.js ADDED
@@ -0,0 +1,123 @@
1
+ /*
2
+ smokeTest.js
3
+ Sends requests to all valid GET and POST paths on the deployed server and verifies that Caddy forwards them to Kilotest (i.e., the response is not a bare Caddy 404).
4
+ */
5
+
6
+ // IMPORTS
7
+
8
+ const https = require('https');
9
+ const {routes} = require('./index');
10
+
11
+ // CONSTANTS
12
+
13
+ const host = process.env.SMOKE_HOST || 'kilotest.com';
14
+ // Concrete paths matching each wildcard pattern in the routes table, for smoke testing.
15
+ const concretePaths = {
16
+ GET: {
17
+ '/mcp': '/mcp',
18
+ '/': '/',
19
+ '/index.html': '/index.html',
20
+ '/robots.txt': '/robots.txt',
21
+ '/openapi.yaml': '/openapi.yaml',
22
+ '/openapi.json': '/openapi.json',
23
+ '/swagger.yaml': '/swagger.yaml',
24
+ '/swagger.json': '/swagger.json',
25
+ '/api-docs': '/api-docs',
26
+ '/llms.txt': '/llms.txt',
27
+ '/llms-full.txt': '/llms-full.txt',
28
+ '/sitemap.xml': '/sitemap.xml',
29
+ '/style.css': '/style.css',
30
+ '/fullReport.json/*': '/fullReport.json/260101T0000/mix',
31
+ '/api/*': '/api/listReports',
32
+ '/tutorial/images/*': '/tutorial/images/diagram.png',
33
+ '/favicon.*': '/favicon.ico',
34
+ '*.html*': '/listReports.html'
35
+ },
36
+ POST: {
37
+ '/mcp': '/mcp',
38
+ '/requestTest.html': '/requestTest.html',
39
+ '/requestRetest.html/*': '/requestRetest.html/260101T0001/ct',
40
+ '/recAction.html': '/recAction.html',
41
+ '/reannotate.html': '/reannotate.html',
42
+ '/renewWCAG.html': '/renewWCAG.html',
43
+ '/worker/job': '/worker/job',
44
+ '/worker/report': '/worker/report',
45
+ '/api/*': '/api/requestFeature',
46
+ '/tutorialComment.html': '/tutorialComment.html'
47
+ }
48
+ };
49
+ // Minimal POST bodies for paths that require them.
50
+ const postBodies = {
51
+ '/api/requestFeature': {feature: 'smoke test'},
52
+ '/requestTest.html': {what: 'Smoke Test Page', url: 'https://smoketest.example.com', why: 'smoke test'},
53
+ '/requestRetest.html/260101T0001/ct': {why: 'smoke test'},
54
+ '/recAction.html': {target: 'https://smoketest.example.com\tSmoke Test Page', authCode: 'invalid'},
55
+ '/reannotate.html': {authCode: 'invalid'},
56
+ '/renewWCAG.html': {authCode: 'invalid'},
57
+ '/worker/job': {},
58
+ '/worker/report': {},
59
+ '/tutorialComment.html': {content: 'smoke test'},
60
+ '/mcp': {}
61
+ };
62
+
63
+ // FUNCTIONS
64
+
65
+ // Sends an HTTPS request and returns the status code and body length.
66
+ const sendRequest = (method, requestPath) => new Promise((resolve, reject) => {
67
+ const body = method === 'POST' ? JSON.stringify(postBodies[requestPath] || {}) : null;
68
+ const options = {
69
+ method,
70
+ host,
71
+ path: requestPath,
72
+ headers: body ? {
73
+ 'content-type': 'application/json; charset=utf-8',
74
+ 'content-length': Buffer.byteLength(body)
75
+ } : {}
76
+ };
77
+ const req = https.request(options, response => {
78
+ const chunks = [];
79
+ response.on('data', chunk => chunks.push(chunk));
80
+ response.on('end', () => {
81
+ const responseBody = chunks.join('');
82
+ resolve({statusCode: response.statusCode, bodyLength: responseBody.length});
83
+ });
84
+ });
85
+ req.on('error', reject);
86
+ req.end(body || '');
87
+ });
88
+
89
+ // Returns whether a response is a bare Caddy 404 (the failure this test detects).
90
+ const isCaddy404 = result => result.statusCode === 404 && result.bodyLength === 0;
91
+
92
+ // EXECUTION
93
+
94
+ (async () => {
95
+ let failures = 0;
96
+ for (const method of ['GET', 'POST']) {
97
+ console.log(`\n=== ${method} paths ===`);
98
+ for (const pattern of routes[method]) {
99
+ const requestPath = concretePaths[method][pattern];
100
+ if (!requestPath) {
101
+ console.log(`FAIL: no concrete path defined for pattern ${pattern}`);
102
+ failures++;
103
+ continue;
104
+ }
105
+ try {
106
+ const result = await sendRequest(method, requestPath);
107
+ if (isCaddy404(result)) {
108
+ console.log(`FAIL: ${method} ${requestPath} -> Caddy 404 (path not forwarded)`);
109
+ failures++;
110
+ }
111
+ else {
112
+ console.log(`PASS: ${method} ${requestPath} -> ${result.statusCode} (body ${result.bodyLength} bytes)`);
113
+ }
114
+ }
115
+ catch (error) {
116
+ console.log(`FAIL: ${method} ${requestPath} -> error: ${error.message}`);
117
+ failures++;
118
+ }
119
+ }
120
+ }
121
+ console.log(`\n=== ${failures === 0 ? 'All paths forwarded' : `${failures} failure(s)`} ===`);
122
+ process.exit(failures === 0 ? 0 : 1);
123
+ })();
@@ -0,0 +1,74 @@
1
+ /*
2
+ listDiagnoses.test.js
3
+ UI tests for web/listDiagnoses/index.js using the fixture corpus.
4
+ */
5
+
6
+ // IMPORTS
7
+
8
+ const {test, before, after} = require('node:test');
9
+ const assert = require('node:assert/strict');
10
+ const path = require('node:path');
11
+ const {parse} = require('node-html-parser');
12
+ const {answer} = require('./index');
13
+
14
+ // SETUP AND TEARDOWN
15
+
16
+ const savedDBDir = process.env.DB_DIR;
17
+
18
+ before(() => {
19
+ process.env.DB_DIR = path.join(__dirname, '..', '..', 'test', 'fixtures', 'db');
20
+ });
21
+
22
+ after(() => {
23
+ if (savedDBDir !== undefined) {
24
+ process.env.DB_DIR = savedDBDir;
25
+ }
26
+ else {
27
+ delete process.env.DB_DIR;
28
+ }
29
+ });
30
+
31
+ // TESTS
32
+
33
+ test('listDiagnoses returns an ok status with valid HTML for catalogIndex 0, linkNoText, in the mixed report', async () => {
34
+ const result = await answer('linkNoText/260101T0000/mix/0');
35
+ assert.equal(result.status, 'ok');
36
+ assert.ok(result.answerPage);
37
+ const html = parse(result.answerPage);
38
+ assert.ok(html.querySelector('title'));
39
+ });
40
+
41
+ test('listDiagnoses includes the issue summary in the HTML', async () => {
42
+ const result = await answer('linkNoText/260101T0000/mix/0');
43
+ assert.ok(result.answerPage.includes('link not named'));
44
+ });
45
+
46
+ test('listDiagnoses includes the element tag name and text from the catalog', async () => {
47
+ const result = await answer('linkNoText/260101T0000/mix/0');
48
+ assert.ok(result.answerPage.includes('About Us'));
49
+ });
50
+
51
+ test('listDiagnoses includes diagnosis descriptions from the rule engines', async () => {
52
+ const result = await answer('linkNoText/260101T0000/mix/0');
53
+ assert.ok(result.answerPage.includes('The link does not have an accessible name'));
54
+ });
55
+
56
+ test('listDiagnoses returns an error status for a hidden report', async () => {
57
+ const result = await answer('linkNoText/260101T0007/hid/0');
58
+ assert.equal(result.status, 'error');
59
+ });
60
+
61
+ test('listDiagnoses returns an error status for a nonexistent report', async () => {
62
+ const result = await answer('linkNoText/999999T9999/xyz/0');
63
+ assert.equal(result.status, 'error');
64
+ });
65
+
66
+ test('listDiagnoses returns ok with no diagnoses for a nonexistent catalog index', async () => {
67
+ const result = await answer('linkNoText/260101T0000/mix/999');
68
+ assert.equal(result.status, 'ok');
69
+ const html = parse(result.answerPage);
70
+ const diagnosisItems = html.querySelectorAll('li');
71
+ // The page may have other li elements, but none should contain diagnosis descriptions.
72
+ const diagnosisTexts = diagnosisItems.map(li => li.text);
73
+ assert.ok(!diagnosisTexts.some(t => t.includes('The link does not have an accessible name')));
74
+ });
@@ -64,9 +64,9 @@ const getIssuesData = async (timeStamp, jobID) => {
64
64
  const instances = result?.standardResult?.instances ?? [];
65
65
  // For each of its standard instances:
66
66
  instances.forEach(instance => {
67
- const {catalogIndex, issueID} = instance;
68
- // If the instance identifies its rule as belonging to a non-ignorable issue:
69
- if (issueID && issueID !== 'ignorable') {
67
+ const {catalogIndex, issueID, outcome} = instance;
68
+ // If the instance reports a violation and identifies a non-ignorable issue:
69
+ if (outcome !== 'cantTell' && issueID && issueID !== 'ignorable') {
70
70
  const issueClassification = issueSpecs[issueID];
71
71
  // If the issue has a current weighted classification:
72
72
  if (issueClassification && [1, 2, 3, 4].includes(issueClassification.weight)) {
@@ -0,0 +1,80 @@
1
+ /*
2
+ listIssues.test.js
3
+ UI tests for web/listIssues/index.js using the fixture corpus.
4
+ */
5
+
6
+ // IMPORTS
7
+
8
+ const {test, before, after} = require('node:test');
9
+ const assert = require('node:assert/strict');
10
+ const path = require('node:path');
11
+ const {parse} = require('node-html-parser');
12
+ const {answer} = require('./index');
13
+
14
+ // SETUP AND TEARDOWN
15
+
16
+ const savedDBDir = process.env.DB_DIR;
17
+
18
+ before(() => {
19
+ process.env.DB_DIR = path.join(__dirname, '..', '..', 'test', 'fixtures', 'db');
20
+ });
21
+
22
+ after(() => {
23
+ if (savedDBDir !== undefined) {
24
+ process.env.DB_DIR = savedDBDir;
25
+ }
26
+ else {
27
+ delete process.env.DB_DIR;
28
+ }
29
+ });
30
+
31
+ // TESTS
32
+
33
+ test('listIssues returns an ok status with valid HTML for the mixed report', async () => {
34
+ const result = await answer('260101T0000/mix');
35
+ assert.equal(result.status, 'ok');
36
+ assert.ok(result.answerPage);
37
+ const html = parse(result.answerPage);
38
+ assert.ok(html.querySelector('title'));
39
+ });
40
+
41
+ test('listIssues includes the page description in the HTML for the mixed report', async () => {
42
+ const result = await answer('260101T0000/mix');
43
+ assert.ok(result.answerPage.includes('Mixed Outcomes Page'));
44
+ });
45
+
46
+ test('listIssues includes links to listViolators for issues in the mixed report', async () => {
47
+ const result = await answer('260101T0000/mix');
48
+ const html = parse(result.answerPage);
49
+ const violatorLinks = html.querySelectorAll('a[href*="listViolators.html"]');
50
+ assert.ok(violatorLinks.length >= 2);
51
+ const hrefs = violatorLinks.map(a => a.getAttribute('href'));
52
+ assert.ok(hrefs.some(href => href.includes('linkNoText')));
53
+ assert.ok(hrefs.some(href => href.includes('allCaps')));
54
+ });
55
+
56
+ test('listIssues returns an error status for a hidden report', async () => {
57
+ const result = await answer('260101T0007/hid');
58
+ assert.equal(result.status, 'error');
59
+ });
60
+
61
+ test('listIssues returns an error status for a nonexistent report', async () => {
62
+ const result = await answer('999999T9999/xyz');
63
+ assert.equal(result.status, 'error');
64
+ });
65
+
66
+ test('listIssues for the all-cantTell report shows no issue links', async () => {
67
+ const result = await answer('260101T0001/ct');
68
+ assert.equal(result.status, 'ok');
69
+ const html = parse(result.answerPage);
70
+ const violatorLinks = html.querySelectorAll('a[href*="listViolators.html"]');
71
+ assert.equal(violatorLinks.length, 0);
72
+ });
73
+
74
+ test('listIssues for the empty report shows no issue links', async () => {
75
+ const result = await answer('260101T0005/emp');
76
+ assert.equal(result.status, 'ok');
77
+ const html = parse(result.answerPage);
78
+ const violatorLinks = html.querySelectorAll('a[href*="listViolators.html"]');
79
+ assert.equal(violatorLinks.length, 0);
80
+ });
@@ -0,0 +1,84 @@
1
+ /*
2
+ listReports.test.js
3
+ UI tests for web/listReports/index.js using the fixture corpus.
4
+ */
5
+
6
+ // IMPORTS
7
+
8
+ const {test, before, after} = require('node:test');
9
+ const assert = require('node:assert/strict');
10
+ const path = require('node:path');
11
+ const {parse} = require('node-html-parser');
12
+ const {answer} = require('./index');
13
+
14
+ // SETUP AND TEARDOWN
15
+
16
+ const savedDBDir = process.env.DB_DIR;
17
+
18
+ before(() => {
19
+ process.env.DB_DIR = path.join(__dirname, '..', '..', 'test', 'fixtures', 'db');
20
+ });
21
+
22
+ after(() => {
23
+ if (savedDBDir !== undefined) {
24
+ process.env.DB_DIR = savedDBDir;
25
+ }
26
+ else {
27
+ delete process.env.DB_DIR;
28
+ }
29
+ });
30
+
31
+ // TESTS
32
+
33
+ test('listReports returns an ok status with valid HTML', async () => {
34
+ const result = await answer();
35
+ assert.equal(result.status, 'ok');
36
+ assert.ok(result.answerPage);
37
+ const html = parse(result.answerPage);
38
+ assert.equal(html.querySelector('title').text, 'Pages tested | Kilotest');
39
+ });
40
+
41
+ test('listReports includes the 6 non-hidden fixture reports as details elements', async () => {
42
+ const result = await answer();
43
+ const html = parse(result.answerPage);
44
+ const details = html.querySelectorAll('details');
45
+ assert.equal(details.length, 6);
46
+ });
47
+
48
+ test('listReports includes the page descriptions in summary elements', async () => {
49
+ const result = await answer();
50
+ const html = parse(result.answerPage);
51
+ const summaries = html.querySelectorAll('details > summary').map(s => s.text);
52
+ assert.ok(summaries.some(s => s.startsWith('Mixed Outcomes Page')));
53
+ assert.ok(summaries.some(s => s.startsWith('All CantTell Page')));
54
+ assert.ok(summaries.some(s => s.startsWith('No Outcomes Page')));
55
+ assert.ok(summaries.some(s => s.startsWith('Empty Results Page')));
56
+ assert.ok(summaries.some(s => s.startsWith('Prevented Page')));
57
+ });
58
+
59
+ test('listReports does not include the hidden report', async () => {
60
+ const result = await answer();
61
+ assert.ok(!result.answerPage.includes('Hidden Page'));
62
+ });
63
+
64
+ test('listReports includes links to listIssues for reports with issues', async () => {
65
+ const result = await answer();
66
+ const html = parse(result.answerPage);
67
+ const issueLinks = html.querySelectorAll('a[href*="listIssues.html"]');
68
+ assert.ok(issueLinks.length > 0);
69
+ const hrefs = issueLinks.map(a => a.getAttribute('href'));
70
+ assert.ok(hrefs.some(href => href.includes('260101T0000/mix')));
71
+ });
72
+
73
+ test('listReports includes the page URLs in the report details', async () => {
74
+ const result = await answer();
75
+ assert.ok(result.answerPage.includes('https://example.com/mixed'));
76
+ assert.ok(result.answerPage.includes('https://example.com/canttell'));
77
+ });
78
+
79
+ test('listReports includes a link to request testing a new page', async () => {
80
+ const result = await answer();
81
+ const html = parse(result.answerPage);
82
+ const testLink = html.querySelector('a[href="requestTestForm.html"]');
83
+ assert.ok(testLink);
84
+ });
@@ -0,0 +1,66 @@
1
+ /*
2
+ listViolators.test.js
3
+ UI tests for web/listViolators/index.js using the fixture corpus.
4
+ */
5
+
6
+ // IMPORTS
7
+
8
+ const {test, before, after} = require('node:test');
9
+ const assert = require('node:assert/strict');
10
+ const path = require('node:path');
11
+ const {parse} = require('node-html-parser');
12
+ const {answer} = require('./index');
13
+
14
+ // SETUP AND TEARDOWN
15
+
16
+ const savedDBDir = process.env.DB_DIR;
17
+
18
+ before(() => {
19
+ process.env.DB_DIR = path.join(__dirname, '..', '..', 'test', 'fixtures', 'db');
20
+ });
21
+
22
+ after(() => {
23
+ if (savedDBDir !== undefined) {
24
+ process.env.DB_DIR = savedDBDir;
25
+ }
26
+ else {
27
+ delete process.env.DB_DIR;
28
+ }
29
+ });
30
+
31
+ // TESTS
32
+
33
+ test('listViolators returns an ok status with valid HTML for linkNoText in the mixed report', async () => {
34
+ const result = await answer('linkNoText/260101T0000/mix');
35
+ assert.equal(result.status, 'ok');
36
+ assert.ok(result.answerPage);
37
+ const html = parse(result.answerPage);
38
+ assert.ok(html.querySelector('title'));
39
+ });
40
+
41
+ test('listViolators includes the issue summary in the HTML', async () => {
42
+ const result = await answer('linkNoText/260101T0000/mix');
43
+ assert.ok(result.answerPage.includes('link not named'));
44
+ });
45
+
46
+ test('listViolators includes links to listDiagnoses for violators', async () => {
47
+ const result = await answer('linkNoText/260101T0000/mix');
48
+ const html = parse(result.answerPage);
49
+ const diagnosisLinks = html.querySelectorAll('a[href*="listDiagnoses.html"]');
50
+ assert.ok(diagnosisLinks.length > 0);
51
+ });
52
+
53
+ test('listViolators returns an error status for a hidden report', async () => {
54
+ const result = await answer('linkNoText/260101T0007/hid');
55
+ assert.equal(result.status, 'error');
56
+ });
57
+
58
+ test('listViolators returns an error status for a nonexistent report', async () => {
59
+ const result = await answer('linkNoText/999999T9999/xyz');
60
+ assert.equal(result.status, 'error');
61
+ });
62
+
63
+ test('listViolators returns an error status for an unknown issue', async () => {
64
+ const result = await answer('nonexistentIssue/260101T0000/mix');
65
+ assert.equal(result.status, 'error');
66
+ });
package/api/testSuite.js DELETED
@@ -1,311 +0,0 @@
1
- /*
2
- © 2026 Jonathan Robert Pool.
3
-
4
- Licensed under the MIT License. See LICENSE file at the project root or
5
- https://opensource.org/license/mit/ for details.
6
-
7
- SPDX-License-Identifier: MIT
8
- */
9
-
10
- /*
11
- researchAgent.js
12
- Simulates a research agent.
13
- */
14
-
15
- // IMPORTS
16
-
17
- require('dotenv').config();
18
- const httpClient = require('http');
19
- const httpsClient = require('https');
20
-
21
- // CONSTANTS
22
-
23
- const kilotestHosts = [
24
- process.env.LOCAL_KILOTEST_HOST || 'http://localhost:3000',
25
- process.env.DEPLOYED_KILOTEST_HOST || 'https://kilotest.com'
26
- ];
27
- // Kilotest host specified by the argument.
28
- const kilotestHost = kilotestHosts[process.argv[2] === 'pub' ? 1 : 0];
29
- const hostParts = kilotestHost.split(/:\/*/);
30
- const scheme = hostParts[0] === 'https' ? 'https' : 'http';
31
- const client = scheme === 'https' ? httpsClient : httpClient;
32
- const host = hostParts[1] || 'localhost';
33
- const port = hostParts[2] || (scheme === 'https' ? 443 : 80);
34
-
35
- // FUNCTIONS
36
-
37
- // Gets, parses into an object, and returns the body of a response.
38
- const getBody = async response => {
39
- const body = await new Promise(resolve => {
40
- // Initialize an array of data from the response.
41
- const chunks = [];
42
- response
43
- // If the response throws an error:
44
- .on('error', error => {
45
- const {message} = error;
46
- // Report and return the error message.
47
- console.log(message);
48
- resolve({error: message});
49
- })
50
- // Whenever the response delivers data:
51
- .on('data', chunk => {
52
- // Add them to the array.
53
- chunks.push(chunk);
54
- })
55
- // When the response is completed:
56
- .on('end', () => {
57
- const bodyString = chunks.join('');
58
- try {
59
- const body = JSON.parse(bodyString);
60
- // Return the response body as an object.
61
- resolve(body);
62
- }
63
- // If it is not JSON:
64
- catch {
65
- // Return this.
66
- resolve({error: `Response body not JSON (${bodyString})`});
67
- }
68
- });
69
- });
70
- return body;
71
- };
72
- const getRequestOptions = (path, method = 'GET') => ({
73
- method,
74
- host,
75
- port,
76
- path,
77
- headers: {
78
- 'body-type': 'application/json; charset=utf-8'
79
- }
80
- });
81
- // Submits a request, returns the response body, and increments the results.
82
- const submitRequest = async (path, method, requestBody = null) => new Promise(resolve => {
83
- console.log(`Making ${scheme} ${method} request on port ${port} of ${host} to ${path}`);
84
- client.request(getRequestOptions(path, method), async response => {
85
- const responseBody = await getBody(response);
86
- resolve(responseBody);
87
- })
88
- .on('error', error => {
89
- console.log(`ERROR submitting request (${JSON.stringify(error, null, 2)})`);
90
- resolve({error});
91
- })
92
- .end(requestBody ? JSON.stringify(requestBody) : '');
93
- });
94
- // Submits requests to the specified Kilotest host.
95
- const requestService = async () => {
96
- let body;
97
- let jobID;
98
- let method;
99
- let path;
100
- let reportBasics;
101
- let reportsBasics;
102
- let requestDetails;
103
- let requestDisposition;
104
- let responseContent;
105
- let timeStamp;
106
- console.log('======================\nRequest: List all available reports');
107
- method = 'GET';
108
- path = '/api/listReports';
109
- body = await submitRequest(path, method);
110
- responseContent = body?.['response content'] ?? {};
111
- reportsBasics = responseContent?.['basics about all available reports'] ?? [];
112
- if (
113
- !reportsBasics.length
114
- || !Array.isArray(reportsBasics)
115
- || !reportsBasics.length
116
- || reportsBasics.some(
117
- reportBasics => reportBasics['how to get details about the report']?.method !== 'GET'
118
- )
119
- ) {
120
- console.log(`reportsBasics: ${JSON.stringify(reportsBasics, null, 2)}`);
121
- return;
122
- }
123
- console.log('======================\nRequest: List issues in one nonexistent report');
124
- [timeStamp, jobID] = ['111111T1111', 'abc'];
125
- method = 'GET';
126
- path = `/api/listIssues/${timeStamp}/${jobID}`;
127
- body = await submitRequest(path, method);
128
- responseContent = body?.['response content'] ?? {};
129
- reportBasics = responseContent?.['basics about the report'] ?? {};
130
- if (
131
- !reportBasics.error || !reportBasics.error.includes('missing, unreadable, or not JSON')
132
- ) {
133
- console.log(`reportBasics: ${JSON.stringify(reportBasics, null, 2)}`);
134
- return;
135
- }
136
- console.log('======================\nRequest: List issues in one report');
137
- const reportsBasicsIndex = Math.floor(reportsBasics.length * Math.random());
138
- // Choose one report at random.
139
- reportBasics = reportsBasics[reportsBasicsIndex];
140
- // Get whether the report has been superseded by a later report about the same page.
141
- const isSuperseded = reportBasics['whether a later report about the same page exists'];
142
- [timeStamp, jobID] = reportBasics.identifier?.split('-') || [null, null];
143
- if (!(timeStamp && jobID)) {
144
- console.log(`reportBasicsIndex: ${reportsBasicsIndex}`);
145
- console.log(`reportBasics: ${JSON.stringify(reportBasics, null, 2)}`);
146
- return;
147
- }
148
- path = `/api/listIssues/${timeStamp}/${jobID}`;
149
- body = await submitRequest(path, method);
150
- responseContent = body?.['response content'] ?? {};
151
- const issuesBasics = responseContent?.['basics about all issues reported in the report'] ?? [];
152
- if (
153
- !issuesBasics.length
154
- || !Array.isArray(issuesBasics)
155
- || !issuesBasics.length
156
- || issuesBasics.some(issueBasics => !issueBasics['impact on a user'])
157
- ) {
158
- console.log(`issuesBasics: ${JSON.stringify(issuesBasics, null, 2)}`);
159
- return;
160
- }
161
- console.log('======================\nRequest: List violators of one issue in one report');
162
- // Get the issue IDs.
163
- const issueIDs = issuesBasics.map(issueBasics => issueBasics.identifier);
164
- // Choose one at random.
165
- const issueID = issueIDs[Math.floor(Math.random() * issueIDs.length)];
166
- path = `/api/listViolators/${issueID}/${timeStamp}/${jobID}`;
167
- body = await submitRequest(path, method);
168
- responseContent = body?.['response content'] ?? {};
169
- const violatorsBasics = responseContent?.['basics about all elements exhibiting the issue'] ?? [];
170
- if (
171
- !violatorsBasics.length
172
- || !Array.isArray(violatorsBasics)
173
- || !violatorsBasics.length
174
- || violatorsBasics.some(violatorBasics => !violatorBasics[
175
- 'count of rule engines reporting that the element exhibited the issue'
176
- ])
177
- ) {
178
- console.log(`violatorsBasics: ${JSON.stringify(violatorsBasics, null, 2)}`);
179
- return;
180
- }
181
- console.log(
182
- '======================\nRequest: List diagnoses of one violation of one issue in one report'
183
- );
184
- // Get the issue IDs.
185
- const violatorIDs = violatorsBasics.map(violatorBasics => violatorBasics.identifier);
186
- // Choose one at random.
187
- const violatorID = violatorIDs[Math.floor(Math.random() * violatorIDs.length)];
188
- path = `/api/listDiagnoses/${violatorID}/${issueID}/${timeStamp}/${jobID}`;
189
- body = await submitRequest(path, method);
190
- responseContent = body?.['response content'] ?? {};
191
- const diagnoses = responseContent?.['diagnoses of how the element exhibited the issue'] ?? [];
192
- if (
193
- !diagnoses.length
194
- || !Array.isArray(diagnoses)
195
- || !diagnoses.length
196
- || diagnoses.some(
197
- diagnosis => typeof diagnosis !== 'object' || !diagnosis['description of the violation']
198
- )
199
- ) {
200
- console.log(`diagnoses: ${JSON.stringify(diagnoses, null, 2)}`);
201
- return;
202
- }
203
- console.log('======================\nRequest: Get one report');
204
- path = `/api/getReport/${timeStamp}/${jobID}`;
205
- body = await submitRequest(path, method);
206
- responseContent = body?.['response content'] ?? {};
207
- const fullReport = responseContent?.['full report'] ?? {};
208
- if (responseContent.error || typeof fullReport !== 'object' || !fullReport.device) {
209
- console.log('Full report request failed');
210
- return;
211
- }
212
- console.log('======================\nRequest: Make a test request with the GET method');
213
- path = '/api/requestTest';
214
- body = await submitRequest(path, method);
215
- if (body.error?.message !== 'Invalid service request') {
216
- console.log(`body: ${JSON.stringify(body, null, 2)}`);
217
- return;
218
- }
219
- console.log('======================\nRequest: Request a test with a too short URL');
220
- method = 'POST';
221
- body = await submitRequest(path, method, {
222
- description: 'Zilch',
223
- URL: 'https://a.b',
224
- reason: 'Just because'
225
- });
226
- responseContent = body?.['response content'] ?? {};
227
- requestDetails = responseContent['details about your request'] ?? {};
228
- if (!requestDetails.error?.includes('specified a URL for the page')) {
229
- console.log(`requestDetails: ${JSON.stringify(requestDetails, null, 2)}`);
230
- return;
231
- }
232
- console.log('======================\nRequest: Request a test');
233
- body = await submitRequest(path, method, {
234
- description: 'Organization that does not exist',
235
- URL: 'https://nonexistentorg.com',
236
- reason: 'I have no good reason for wanting this page to be tested'
237
- });
238
- responseContent = body?.['response content'] ?? {};
239
- requestDetails = responseContent['details about your request'] ?? {};
240
- requestDisposition = responseContent['disposition of your request'] ?? {};
241
- if (
242
- !requestDetails['reason why the page should be tested']
243
- || !requestDisposition['what happens next']?.includes('1 hour to 1 day')
244
- ) {
245
- console.log(`requestDetails: ${JSON.stringify(requestDetails, null, 2)}`);
246
- console.log(`requestDisposition: ${JSON.stringify(requestDisposition, null, 2)}`);
247
- return;
248
- }
249
- console.log('======================\nRequest: Request a retest of a nonexistent report');
250
- path = `/api/requestRetest/${timeStamp}/xyz`;
251
- body = await submitRequest(path, method, {
252
- reason: 'It would be cool to retest what does not exist'
253
- });
254
- responseContent = body?.['response content'] ?? {};
255
- requestDetails = responseContent['details about your request'] ?? {};
256
- if (!requestDetails.error?.includes('not an available report')) {
257
- console.log(`requestDetails: ${JSON.stringify(requestDetails, null, 2)}`);
258
- return;
259
- }
260
- console.log('======================\nRequest: Request a retest');
261
- path = `/api/requestRetest/${timeStamp}/${jobID}`;
262
- body = await submitRequest(path, method, {
263
- reason: 'I just feel like making this request'
264
- });
265
- responseContent = body?.['response content'] ?? {};
266
- requestDetails = responseContent['details about your request'] ?? {};
267
- requestDisposition = responseContent['disposition of your request'] ?? {};
268
- // If the report has been superseded:
269
- if (isSuperseded) {
270
- // Expect the request to be rejected.
271
- if (!requestDetails.error?.includes('a later report about the page exists')) {
272
- console.log(`requestDetails: ${JSON.stringify(requestDetails, null, 2)}`);
273
- return;
274
- }
275
- }
276
- // Otherwise, i.e. if the report has not been superseded:
277
- else {
278
- if (
279
- !requestDetails['reason why the page should be retested']
280
- || !requestDisposition['how you can check for completion']
281
- ) {
282
- console.log(`requestDetails: ${JSON.stringify(requestDetails, null, 2)}`);
283
- console.log(`requestDisposition: ${JSON.stringify(requestDisposition, null, 2)}`);
284
- return;
285
- }
286
- }
287
- console.log('======================\nRequest: Make a feature request');
288
- path = '/api/requestFeature';
289
- const featureRequest = 'Do the impossible';
290
- body = await submitRequest(path, method, {
291
- feature: featureRequest
292
- });
293
- const feature = body?.['this request']?.body?.feature ?? {};
294
- requestDetails = body?.['response content']?.['details about your request'] ?? {};
295
- if (
296
- feature !== featureRequest
297
- || !requestDetails.disposition?.includes('received and logged')
298
- ) {
299
- console.log(`requestDetails: ${JSON.stringify(requestDetails, null, 2)}`);
300
- return;
301
- }
302
- console.log('======================\nRequest: Results');
303
- console.log('All tests succeeded');
304
- };
305
-
306
- // EXECUTION
307
-
308
- // Execute the research agent.
309
- (async () => {
310
- await requestService();
311
- })();