@jrpool/kilotest 24.0.4

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.
Files changed (69) hide show
  1. package/.claude/settings.local.json +9 -0
  2. package/DEVELOPMENT.md +5860 -0
  3. package/LICENSE +21 -0
  4. package/README.md +44 -0
  5. package/SERVICE.md +268 -0
  6. package/aceconfig.js +28 -0
  7. package/ai0BalanceForm/index.html +30 -0
  8. package/ai0BalanceForm/index.js +79 -0
  9. package/alerts.js +73 -0
  10. package/diagnoses/index.html +46 -0
  11. package/diagnoses/index.js +140 -0
  12. package/env.example +21 -0
  13. package/env.testaro +17 -0
  14. package/error.html +18 -0
  15. package/eslint.config.mjs +53 -0
  16. package/favicon.ico +0 -0
  17. package/index.html +39 -0
  18. package/index.js +639 -0
  19. package/issues/index.html +20 -0
  20. package/issues/index.js +173 -0
  21. package/job.json +100 -0
  22. package/manage/index.html +32 -0
  23. package/manage/index.js +22 -0
  24. package/package.json +38 -0
  25. package/pm2.config.js +15 -0
  26. package/reannotate/index.html +19 -0
  27. package/reannotate/index.js +39 -0
  28. package/reannotateForm/index.html +29 -0
  29. package/reannotateForm/index.js +114 -0
  30. package/recActionForm/index.html +33 -0
  31. package/recActionForm/index.js +49 -0
  32. package/reportHideForm/index.html +29 -0
  33. package/reportHideForm/index.js +89 -0
  34. package/reportIssue/index.html +38 -0
  35. package/reportIssue/index.js +181 -0
  36. package/reportIssues/index.html +47 -0
  37. package/reportIssues/index.js +259 -0
  38. package/reportUnhideForm/index.html +29 -0
  39. package/reportUnhideForm/index.js +89 -0
  40. package/reportsExpungeForm/index.html +29 -0
  41. package/reportsExpungeForm/index.js +105 -0
  42. package/reportsPruneForm/index.html +29 -0
  43. package/reportsPruneForm/index.js +105 -0
  44. package/reportsRewindForm/index.html +29 -0
  45. package/reportsRewindForm/index.js +105 -0
  46. package/retestRec/index.html +23 -0
  47. package/retestRec/index.js +19 -0
  48. package/retestRecForm/index.html +27 -0
  49. package/retestRecForm/index.js +36 -0
  50. package/rules/index.html +28 -0
  51. package/rules/index.js +71 -0
  52. package/style.css +196 -0
  53. package/targets/index.html +37 -0
  54. package/targets/index.js +170 -0
  55. package/testOrder/index.html +23 -0
  56. package/testOrder/index.js +62 -0
  57. package/testRec/index.html +23 -0
  58. package/testRec/index.js +25 -0
  59. package/testRecForm/index.html +34 -0
  60. package/testRecForm/index.js +22 -0
  61. package/tutorial/images/newsletter-form.png +0 -0
  62. package/tutorial/index.html +796 -0
  63. package/tutorial/index.js +53 -0
  64. package/util.js +686 -0
  65. package/wcagMap.json +102 -0
  66. package/wcagRenew/index.html +19 -0
  67. package/wcagRenew/index.js +70 -0
  68. package/wcagRenewForm/index.html +25 -0
  69. package/wcagRenewForm/index.js +22 -0
package/index.js ADDED
@@ -0,0 +1,639 @@
1
+ /*
2
+ index.js
3
+ Manages Kilotest.
4
+ */
5
+
6
+ // ENVIRONMENT
7
+
8
+ // Module to keep secrets local.
9
+ require('dotenv').config({quiet: true});
10
+
11
+ // IMPORTS
12
+ const {
13
+ annotateReport,
14
+ getJobNames,
15
+ getJSON,
16
+ getLogPath,
17
+ getObject,
18
+ getPOSTData,
19
+ getReport,
20
+ getRecs,
21
+ getReportPath,
22
+ isHidden,
23
+ isTimeStamp,
24
+ isJobID,
25
+ jobsPath,
26
+ logsPath,
27
+ reportsPath,
28
+ ruleIDs
29
+ } = require('./util');
30
+ const fs = require('fs/promises');
31
+ const http = require('http');
32
+ const https = require('https');
33
+ const path = require('path');
34
+ const {sendAlert} = require('./alerts');
35
+ const answer = {
36
+ ai0BalanceForm: require('./ai0BalanceForm/index').answer,
37
+ diagnoses: require('./diagnoses/index').answer,
38
+ issues: require('./issues/index').answer,
39
+ manage: require('./manage/index').answer,
40
+ reannotate: require('./reannotate/index').answer,
41
+ reannotateForm: require('./reannotateForm/index').answer,
42
+ recActionForm: require('./recActionForm/index').answer,
43
+ reportIssue: require('./reportIssue/index').answer,
44
+ reportIssues: require('./reportIssues/index').answer,
45
+ reportsExpungeForm: require('./reportsExpungeForm/index').answer,
46
+ reportHideForm: require('./reportHideForm/index').answer,
47
+ reportsPruneForm: require('./reportsPruneForm/index').answer,
48
+ reportsRewindForm: require('./reportsRewindForm/index').answer,
49
+ reportUnhideForm: require('./reportUnhideForm/index').answer,
50
+ retestRec: require('./retestRec/index').answer,
51
+ retestRecForm: require('./retestRecForm/index').answer,
52
+ rules: require('./rules/index').answer,
53
+ targets: require('./targets/index').answer,
54
+ testOrder: require('./testOrder/index').answer,
55
+ testRec: require('./testRec/index').answer,
56
+ testRecForm: require('./testRecForm/index').answer,
57
+ tutorial: require('./tutorial/index').answer,
58
+ wcagRenew: require('./wcagRenew/index').answer,
59
+ wcagRenewForm: require('./wcagRenewForm/index').answer
60
+ };
61
+
62
+ // CONSTANTS
63
+
64
+ const protocol = process.env.PROTOCOL || 'http';
65
+ const queuePath = path.join(jobsPath, 'queue');
66
+ const claimedPath = path.join(jobsPath, 'claimed');
67
+ const failedPath = path.join(jobsPath, 'failed');
68
+ const testaroAgent = process.env.TESTARO_AGENT;
69
+ const testaroAgentPW = process.env.TESTARO_AGENT_PW;
70
+ // Values that may require alerts.
71
+ const balancePath = path.join(__dirname, 'aiService0Balance.json');
72
+ const WAVE_THRESHOLD = Number(process.env.WAVE_BALANCE_THRESHOLD);
73
+ const AI_SERVICE0_THRESHOLD = Number(process.env.AI_SERVICE0_BALANCE_THRESHOLD);
74
+ const AI_MODEL0_INPUT_PRICE = Number(process.env.AI_MODEL0_INPUT_PRICE);
75
+ const AI_MODEL0_OUTPUT_PRICE = Number(process.env.AI_MODEL0_OUTPUT_PRICE);
76
+
77
+ // FUNCTIONS
78
+
79
+ // Serves an error message.
80
+ const serveError = async (error, response, isHumanUser = true) => {
81
+ console.log(error.message);
82
+ if (! response.writableEnded) {
83
+ response.statusCode = 400;
84
+ if (isHumanUser) {
85
+ response.setHeader('content-type', 'text/html; charset=utf-8');
86
+ const errorTemplate = await fs.readFile('error.html', 'utf8');
87
+ const errorPage = errorTemplate.replace(/__error__/, error.message);
88
+ response.end(errorPage);
89
+ } else {
90
+ response.setHeader('content-type', 'application/json; charset=utf-8');
91
+ response.end(JSON.stringify({error: error.message}));
92
+ }
93
+ }
94
+ };
95
+ // Checks a report for balances nearing exhaustion.
96
+ const checkBalancesForAlerts = async report => {
97
+ // If the variables to be monitored for alerts are defined:
98
+ if (WAVE_THRESHOLD && AI_SERVICE0_THRESHOLD && AI_MODEL0_INPUT_PRICE && AI_MODEL0_OUTPUT_PRICE) {
99
+ // WAVE.
100
+ const waveAct = report.acts.find(act => act.type === 'test' && act.which === 'wave');
101
+ const creditsRemaining = waveAct?.data?.creditsRemaining;
102
+ // If a WAVE balance nearing exhaustion is reported:
103
+ if (typeof creditsRemaining === 'number' && creditsRemaining < WAVE_THRESHOLD) {
104
+ // Alert a manager.
105
+ await sendAlert(
106
+ 'Kilotest: WAVE balance low',
107
+ `Only ${creditsRemaining} WAVE credits remain (3 used per job)`
108
+ );
109
+ }
110
+ // AI service 0.
111
+ const testaroAct = report.acts.find(act => act.type === 'test' && act.which === 'testaro');
112
+ // Get the AI model token usage for the testaro allCaps test.
113
+ const usage = testaroAct?.data?.ruleData?.allCaps?.aiModelUsage;
114
+ let balanceJSON = null;
115
+ try {
116
+ // Get the recorded AI service 0 balance.
117
+ balanceJSON = await fs.readFile(balancePath, 'utf8');
118
+ }
119
+ catch (error) {
120
+ console.error('ERROR: AI service 0 balance file missing');
121
+ }
122
+ // If the variables required for an AI service 0 balance alert are defined:
123
+ if (usage && AI_MODEL0_INPUT_PRICE && AI_MODEL0_OUTPUT_PRICE && balanceJSON) {
124
+ const inputCost = AI_MODEL0_INPUT_PRICE * usage.inputTokens;
125
+ const outputCost = AI_MODEL0_OUTPUT_PRICE * usage.outputTokens;
126
+ const cost = inputCost + outputCost;
127
+ // If any cost was incurred:
128
+ if (cost > 0) {
129
+ // Warn about this.
130
+ console.log(
131
+ 'This job has made the production AI service 0 balance record wrong. Update it.'
132
+ );
133
+ }
134
+ try {
135
+ const balanceData = JSON.parse(balanceJSON);
136
+ // Get an estimate of the balance after this job.
137
+ const newBalance = balanceData.balance - cost;
138
+ if (typeof newBalance === 'number') {
139
+ // Update the recorded balance.
140
+ await fs.writeFile(balancePath, getJSON({balance: newBalance}));
141
+ // If it is nearing exhaustion:
142
+ if (newBalance < AI_SERVICE0_THRESHOLD) {
143
+ // Alert a manager.
144
+ await sendAlert(
145
+ 'Kilotest: AI service 0 balance low',
146
+ `Balance of AI service 0 account (https://console.anthropic.com) only about $${newBalance.toFixed(2)} (about $0.01 used per job)`
147
+ );
148
+ }
149
+ }
150
+ else {
151
+ console.log('ERROR: AI service 0 balance is not a number');
152
+ }
153
+ }
154
+ catch (error) {
155
+ console.log(`ERROR managing AI service 0 balance: ${error.message}`);
156
+ }
157
+ }
158
+ }
159
+ };
160
+ // Handles a request.
161
+ const requestHandler = async (request, response) => {
162
+ const {method, url} = request;
163
+ const requestURL = new URL(url, 'https://localhost:3000');
164
+ const {pathname, search} = requestURL;
165
+ const pageName = pathname.split('/')[1];
166
+ const pageArgs = pathname.split('/').slice(2).join('/');
167
+ // If the request is an OPTIONS request:
168
+ if (method === 'OPTIONS') {
169
+ // Serve response headers, including one allowing requests from other applications.
170
+ response.setHeader('Access-Control-Allow-Origin', '*');
171
+ response.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
172
+ response.setHeader('Access-Control-Allow-Headers', 'Content-Type');
173
+ response.statusCode = 204;
174
+ response.end();
175
+ }
176
+ // Otherwise, if the request is a GET request:
177
+ else if (method === 'GET') {
178
+ // If it is for the home page:
179
+ if (['/', '/index.html'].includes(pathname)) {
180
+ // Get the home page.
181
+ const homePage = await fs.readFile('index.html', 'utf8');
182
+ // Serve it.
183
+ response.setHeader('content-type', 'text/html; charset=utf-8');
184
+ response.setHeader('content-location', '/index.html');
185
+ response.setHeader('Access-Control-Allow-Origin', '*');
186
+ response.end(homePage);
187
+ }
188
+ // Otherwise, if it is for a full report download:
189
+ else if (pageName === 'fullReport.html') {
190
+ const [timeStamp, jobID] = pageArgs.split('/');
191
+ // If the request is syntactically valid:
192
+ if (isTimeStamp(timeStamp) && isJobID(jobID)) {
193
+ const reportHidden = await isHidden(timeStamp, jobID);
194
+ // If the report exists and is hidden:
195
+ if (reportHidden === true) {
196
+ console.log(`ERROR: Hidden report ${timeStamp}-${jobID} requested`);
197
+ // Report this.
198
+ await serveError(
199
+ {message: `ERROR: requested report ${timeStamp}-${jobID} not available`},
200
+ response,
201
+ true
202
+ );
203
+ }
204
+ // Otherwise, if any other error occurred:
205
+ else if (typeof reportHidden === 'string') {
206
+ // Report it.
207
+ await serveError({message: reportHidden}, response, true);
208
+ }
209
+ // Otherwise, i.e. if the report log is valid and not hidden:
210
+ else {
211
+ // Get the report.
212
+ const report = await getReport(timeStamp, jobID);
213
+ // If it exists and is valid:
214
+ if (typeof report === 'object') {
215
+ // Serve response headers for a JSON download.
216
+ response.setHeader('content-type', 'application/json; charset=utf-8');
217
+ response.setHeader(
218
+ 'content-disposition', `attachment; filename="${timeStamp}-${jobID}.json"`,
219
+ );
220
+ response.setHeader('Access-Control-Allow-Origin', '*');
221
+ // Download the report.
222
+ response.end(getJSON(report));
223
+ }
224
+ // Otherwise, i.e. if an error occurred:
225
+ else {
226
+ // Report it.
227
+ await serveError({message: report}, response, true);
228
+ }
229
+ }
230
+ }
231
+ // Otherwise, i.e. if the request is syntactically invalid:
232
+ else {
233
+ // Report the error.
234
+ await serveError({message: 'Invalid report request'}, response, true);
235
+ }
236
+ }
237
+ // Otherwise, if it is for an HTML page other than the home page:
238
+ else if (pageName.endsWith('.html')) {
239
+ const topic = pageName.slice(0, -5);
240
+ // If the page can be generated:
241
+ if (answer[topic]) {
242
+ // Serve response headers, including one allowing requests from other applications.
243
+ response.setHeader('content-type', 'text/html; charset=utf-8');
244
+ response.setHeader('content-location', `${pathname}${search}`);
245
+ // Get the answer data.
246
+ const answerData = await answer[topic](pageArgs, search);
247
+ // If they are valid:
248
+ if (answerData.status === 'ok') {
249
+ // Serve the answer page.
250
+ response.end(answerData.answerPage);
251
+ }
252
+ // Otherwise, i.e. if they are invalid:
253
+ else {
254
+ // Report the error.
255
+ await serveError({message: answerData.error}, response, true);
256
+ }
257
+ }
258
+ // Otherwise, i.e. if the answer cannot be generated:
259
+ else {
260
+ // Report the error.
261
+ await serveError({message: 'Invalid request'}, response, true);
262
+ }
263
+ }
264
+ // Otherwise, if it is for a tutorial image:
265
+ else if (pathname.startsWith('/tutorial/images/')) {
266
+ const imgFile = pathname.slice('/tutorial/images/'.length);
267
+ const imgPath = path.join(__dirname, 'tutorial', 'images', imgFile);
268
+ try {
269
+ const img = await fs.readFile(imgPath);
270
+ const ext = path.extname(imgFile).toLowerCase();
271
+ const mimeTypes = {'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml'};
272
+ response.setHeader('content-type', mimeTypes[ext] || 'application/octet-stream');
273
+ response.setHeader('cache-control', 'public, max-age=3600');
274
+ response.end(img);
275
+ }
276
+ catch (_) {
277
+ await serveError({message: 'Image not found'}, response, true);
278
+ }
279
+ }
280
+ // Otherwise, if it is for the application icon:
281
+ else if (pathname.includes('favicon.')) {
282
+ // Get the site icon.
283
+ const icon = await fs.readFile(path.join(__dirname, 'favicon.ico'));
284
+ // Serve it.
285
+ response.setHeader('content-type', 'image/x-icon');
286
+ response.write(icon, 'binary');
287
+ response.end('');
288
+ }
289
+ // Otherwise, if it is for the stylesheet:
290
+ else if (pathname === '/style.css') {
291
+ try {
292
+ // Serve it.
293
+ const styleSheet = await fs.readFile('style.css', 'utf8');
294
+ response.writeHead(200, {
295
+ 'content-type': 'text/css; charset=utf-8',
296
+ 'cache-control': 'public, max-age=600'
297
+ });
298
+ response.end(styleSheet);
299
+ }
300
+ catch (error) {
301
+ await serveError({message: error.message}, response, true);
302
+ }
303
+ }
304
+ // Otherwise, i.e. if it is any other GET request:
305
+ else {
306
+ // Report the error.
307
+ await serveError(
308
+ {message: `ERROR: Invalid GET request (${pathname}${search})`}, response, true
309
+ );
310
+ }
311
+ }
312
+ // Otherwise, if the request is a POST request:
313
+ else if (method === 'POST') {
314
+ // Get the data from the request body.
315
+ const postData = await getPOSTData(request);
316
+ // If the request is a retest recommendation:
317
+ if (pageName === 'retestRec.html') {
318
+ const {why} = postData;
319
+ const [timeStamp, jobID] = pageArgs.split('/');
320
+ // If the request is valid:
321
+ if (isTimeStamp(timeStamp) && isJobID(jobID) && why) {
322
+ // Serve response headers, including one allowing requests from other applications.
323
+ response.setHeader('content-type', 'text/html; charset=utf-8');
324
+ response.setHeader('content-location', `${pathname}${search}`);
325
+ response.setHeader('Access-Control-Allow-Origin', '*');
326
+ // Get the answer data.
327
+ const answerData = await require(path.join(__dirname, 'retestRec', 'index'))
328
+ .answer(pageArgs, why);
329
+ // If they are valid:
330
+ if (answerData.status === 'ok') {
331
+ // Serve the answer page.
332
+ response.end(answerData.answerPage);
333
+ }
334
+ // Otherwise, i.e. if they are invalid:
335
+ else {
336
+ // Report the error.
337
+ await serveError({message: answerData.error}, response, true);
338
+ }
339
+ }
340
+ // Otherwise, i.e. if the request is invalid:
341
+ else {
342
+ // Report the error.
343
+ await serveError({message: 'Invalid retest recommendation'}, response, true);
344
+ }
345
+ }
346
+ // Otherwise, if it is a test recommendation:
347
+ else if (pageName === 'testRec.html') {
348
+ const {what, url, why} = postData;
349
+ // If the request is valid:
350
+ if (what && url.startsWith('https://') && why) {
351
+ // Serve headers for a response.
352
+ response.setHeader('content-type', 'text/html; charset=utf-8');
353
+ response.setHeader('content-location', `${pathname}${search}`);
354
+ // Get the answer data.
355
+ const answerData = await require(path.join(__dirname, 'testRec', 'index'))
356
+ .answer(what, url, why);
357
+ // If they are valid:
358
+ if (answerData.status === 'ok') {
359
+ // Serve the answer page.
360
+ response.end(answerData.answerPage);
361
+ }
362
+ // Otherwise, i.e. if they are invalid:
363
+ else {
364
+ // Report the error.
365
+ await serveError({message: answerData.error}, response, true);
366
+ }
367
+ }
368
+ // Otherwise, i.e. if the request is invalid:
369
+ else {
370
+ // Report the error.
371
+ await serveError({message: 'Invalid test recommendation'}, response, true);
372
+ }
373
+ }
374
+ // Otherwise, if it is an action on a test or retest recommendation:
375
+ else if (pageName === 'recAction.html') {
376
+ const {target, authCode} = postData;
377
+ const [url, what] = target.split('\t');
378
+ // If the request is valid:
379
+ if (url.startsWith('https://') && authCode === process.env.AUTH_CODE) {
380
+ // Serve a content-type header for a response.
381
+ response.setHeader('content-type', 'text/html; charset=utf-8');
382
+ // If the request is an approval:
383
+ if (what) {
384
+ // Serve a location header for a response.
385
+ response.setHeader('content-location', `${pathname}${search}`);
386
+ // Get the answer data.
387
+ const answerData = await require(path.join(__dirname, 'testOrder', 'index'))
388
+ .answer(url, what, authCode);
389
+ // If the answer data are valid:
390
+ if (answerData.status === 'ok') {
391
+ // Serve the answer page.
392
+ response.end(answerData.answerPage);
393
+ }
394
+ // Otherwise, i.e. if they are invalid:
395
+ else {
396
+ // Report the error.
397
+ await serveError({message: answerData.error}, response, true);
398
+ }
399
+ }
400
+ // Otherwise, i.e. if it is a rejection:
401
+ else {
402
+ // Get the recommendations.
403
+ const recs = await getRecs();
404
+ // Delete the rejected URL.
405
+ delete recs[url];
406
+ // Save the revised recommendations.
407
+ await fs.writeFile(path.join(__dirname, 'jobs', 'recs.json'), getJSON(recs));
408
+ // Serve a location header for a response.
409
+ response.setHeader('content-location', '/recActionForm.html');
410
+ // Get the answer data.
411
+ const answerData = await require(path.join(__dirname, 'recActionForm', 'index')).answer();
412
+ // Serve the test-order form.
413
+ response.end(answerData.answerPage);
414
+ }
415
+ }
416
+ // Otherwise, i.e. if the request is invalid:
417
+ else {
418
+ // Report the error.
419
+ await serveError({message: 'Invalid test order'}, response, true);
420
+ }
421
+ }
422
+ // Otherwise, if it is a reannotation order:
423
+ else if (pageName === 'reannotate.html') {
424
+ const {authCode} = postData;
425
+ // Serve headers for a response.
426
+ response.setHeader('content-type', 'text/html; charset=utf-8');
427
+ response.setHeader('content-location', `${pathname}${search}`);
428
+ // Get the answer data.
429
+ const answerData = await require(path.join(__dirname, 'reannotate', 'index'))
430
+ .answer(authCode);
431
+ // If the answer data are valid:
432
+ if (answerData.status === 'ok') {
433
+ // Serve the answer page.
434
+ response.end(answerData.answerPage);
435
+ }
436
+ // Otherwise, i.e. if they are invalid:
437
+ else {
438
+ // Report the error.
439
+ await serveError({message: answerData.error}, response, true);
440
+ }
441
+ }
442
+ // Otherwise, if it is a WCAG map renewal:
443
+ else if (pageName === 'wcagRenew.html') {
444
+ const {authCode} = postData;
445
+ // Serve headers for a response.
446
+ response.setHeader('content-type', 'text/html; charset=utf-8');
447
+ response.setHeader('content-location', `${pathname}${search}`);
448
+ // Get the answer data.
449
+ const answerData = await require(path.join(__dirname, 'wcagRenew', 'index'))
450
+ .answer(authCode);
451
+ // If the answer data are valid:
452
+ if (answerData.status === 'ok') {
453
+ // Serve the answer page.
454
+ response.end(answerData.answerPage);
455
+ }
456
+ // Otherwise, i.e. if they are invalid:
457
+ else {
458
+ // Report the error.
459
+ await serveError({message: answerData.error}, response, true);
460
+ }
461
+ }
462
+ // Otherwise, if it is a request from a Testaro agent:
463
+ else if (pageName === 'api') {
464
+ const [agentID, service] = pageArgs.split('/');
465
+ // If the agent is authorized:
466
+ if (agentID === testaroAgent && postData.agentPW === testaroAgentPW) {
467
+ // If the service is job assignment:
468
+ if (service === 'job') {
469
+ let clean = true;
470
+ const messageStart = `Testaro agent ${agentID} requested a job, `;
471
+ const jobNames = await getJobNames();
472
+ const claimedJobNames = jobNames.claimed;
473
+ // For each claimed job:
474
+ for (const jobName of claimedJobNames) {
475
+ const job = await getObject(path.join(jobsPath, 'claimed', jobName));
476
+ const {id, sources} = job;
477
+ const {agent} = sources;
478
+ // If its assignee is the agent:
479
+ if (agent === agentID) {
480
+ const messageEnd = `but has not completed job ${id}`;
481
+ // Report this.
482
+ await serveError({message: `${messageStart}${messageEnd}`}, response, false);
483
+ // Reclassify the job as failed.
484
+ await fs.rename(
485
+ path.join(claimedPath, jobName), path.join(failedPath, jobName)
486
+ );
487
+ clean = false;
488
+ // Stop checking claimed jobs.
489
+ break;
490
+ }
491
+ }
492
+ // If no aborted-job error was found for the agent:
493
+ if (clean) {
494
+ const queuedJobNames = jobNames.queue;
495
+ // If any jobs are queued:
496
+ if (queuedJobNames.length) {
497
+ const oldestJobName = queuedJobNames[0];
498
+ // Get the first one.
499
+ const firstJob = await getObject(path.join(queuePath, oldestJobName));
500
+ // Add the agent ID to the job.
501
+ firstJob.sources.agent = agentID;
502
+ console.log(
503
+ `Job ${firstJob.id} (${firstJob.target.what}) is being sent to the agent.`
504
+ );
505
+ // Assign the job to the agent.
506
+ response.writeHead(200, {
507
+ 'content-type': 'application/json; charset=utf-8'
508
+ });
509
+ response.end(JSON.stringify(firstJob));
510
+ const messageEnd
511
+ = `and job ${firstJob.id} (${firstJob.target.what}) was assigned to the agent`;
512
+ console.log(`${messageStart}${messageEnd}`);
513
+ // Move the job from the queue to the claimed-jobs directory.
514
+ await fs.rename(
515
+ path.join(queuePath, oldestJobName), path.join(claimedPath, oldestJobName)
516
+ );
517
+ }
518
+ // Otherwise, i.e. if no jobs are queued:
519
+ else {
520
+ response.writeHead(200, {
521
+ 'content-type': 'application/json; charset=utf-8'
522
+ });
523
+ // Send a no-jobs response to the agent.
524
+ response.end(JSON.stringify({}));
525
+ const messageEnd = 'but no job was in the queue';
526
+ console.log(`${messageStart}${messageEnd}`);
527
+ }
528
+ }
529
+ }
530
+ // Otherwise, if the service is report acquisition:
531
+ else if (service === 'report') {
532
+ const {report} = postData;
533
+ const {id, target} = report;
534
+ const {what, url} = target;
535
+ const [timeStamp, jobID] = id?.split('-') ?? ['', ''];
536
+ // If the request is valid:
537
+ if (id && isTimeStamp(timeStamp) && isJobID(jobID) && what && url) {
538
+ // Acknowledge receipt.
539
+ response.setHeader('content-type', 'application/json; charset=utf-8');
540
+ response.end(JSON.stringify({status: 'ok'}));
541
+ console.log(`Testaro report ${id} was received from Testaro agent ${agentID}`);
542
+ const [timeStamp, jobID] = id.split('-');
543
+ // Save the report.
544
+ await fs.writeFile(getReportPath(timeStamp, jobID), getJSON(report));
545
+ // Create a log for the report.
546
+ const log = {
547
+ what,
548
+ url
549
+ };
550
+ // Save the log.
551
+ await fs.writeFile(getLogPath(timeStamp, jobID), getJSON(log));
552
+ // Annotate the report and mark it as annotated in the log.
553
+ await annotateReport(ruleIDs, timeStamp, jobID);
554
+ console.log(`Testaro report ${id} was annotated, saved, and logged`);
555
+ // Check the monetary balances and send alerts if nearing exhaustion.
556
+ await checkBalancesForAlerts(report);
557
+ // Delete the job.
558
+ await fs.unlink(path.join(claimedPath, `${id}.json`));
559
+ console.log(`Completed job ${id} deleted`);
560
+ }
561
+ // Otherwise, i.e. if the request is invalid:
562
+ else {
563
+ await serveError({message: 'ERROR: Report invalid'}, response, false);
564
+ }
565
+ }
566
+ // Otherwise, if the service is not valid:
567
+ else {
568
+ await serveError(
569
+ {message: 'ERROR: Invalid service request from Testaro agent'}, response, false
570
+ );
571
+ }
572
+ }
573
+ // Otherwise, i.e. if the agent is not authorized:
574
+ else {
575
+ await serveError({message: 'ERROR: Invalid Testaro agent'}, response, false);
576
+ }
577
+ }
578
+ // Otherwise, if it is a tutorial comment:
579
+ else if (pageName === 'tutorialComment.html') {
580
+ const {content} = postData;
581
+ response.setHeader('content-type', 'application/json; charset=utf-8');
582
+ response.setHeader('Access-Control-Allow-Origin', '*');
583
+ const answerData = await require(path.join(__dirname, 'tutorial', 'index')).saveComment(content);
584
+ if (answerData.status === 'ok') {
585
+ response.end(JSON.stringify({status: 'ok'}));
586
+ }
587
+ else {
588
+ response.statusCode = 400;
589
+ response.end(JSON.stringify({status: 'error', message: answerData.error}));
590
+ }
591
+ }
592
+ // Otherwise, i.e. if it is any other POST request:
593
+ else {
594
+ // Report its invalidity.
595
+ await serveError({message: 'ERROR: Invalid POST request'}, response, true);
596
+ }
597
+ }
598
+ // Otherwise, i.e. if it is neither a GET nor a POST request:
599
+ else {
600
+ // Report its invalidity.
601
+ await serveError({message: 'ERROR: Invalid request method'}, response, true);
602
+ }
603
+ };
604
+
605
+ // SERVER
606
+
607
+ const serve = async (protocolModule, options) => {
608
+ // Create any missing directories.
609
+ for (const path of [queuePath, claimedPath, failedPath, logsPath, reportsPath]) {
610
+ await fs.mkdir(path, {recursive: true});
611
+ }
612
+ const server = protocolModule === 'https'
613
+ ? https.createServer(options, requestHandler)
614
+ : http.createServer(requestHandler);
615
+ const port = process.env.PORT || '3000';
616
+ server.listen(port, () => {
617
+ console.log(`Kilotest server listening at ${protocol}://localhost:${port}.`);
618
+ });
619
+ };
620
+ if (protocol === 'http') {
621
+ console.log('Starting HTTP server');
622
+ serve(http, {});
623
+ }
624
+ else if (protocol === 'https') {
625
+ console.log('Starting HTTPS server');
626
+ fs.readFile(process.env.KEY, 'utf8')
627
+ .then(
628
+ key => {
629
+ fs.readFile(process.env.CERT, 'utf8')
630
+ .then(
631
+ cert => {
632
+ serve(https, {key, cert});
633
+ },
634
+ error => console.log(error.message)
635
+ );
636
+ },
637
+ error => console.log(error.message)
638
+ );
639
+ }
@@ -0,0 +1,20 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en-US">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="publisher" content="Jonathan Robert Pool">
7
+ <meta name="creator" content="Jonathan Robert Pool">
8
+ <meta name="keywords" content="report,accessibility,a11y">
9
+ <title>Issues reported | Kilotest</title>
10
+ <link rel="icon" href="/favicon.ico">
11
+ <link rel="stylesheet" href="/style.css">
12
+ </head>
13
+ <body>
14
+ <main>
15
+ <h1><a href="/">Kilotest</a>: Frequently reported issues</h1>
16
+ <p>These are the issues reported most often. If a page has been tested more than once, only its last test is counted.</p>
17
+ __issues__
18
+ </main>
19
+ </body>
20
+ </html>