@jrpool/kilotest 46.1.2 → 48.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/.claude/settings.local.json +13 -1
- package/.devin/config.local.json +5 -1
- package/.github/workflows/ci.yml +0 -3
- package/.github/workflows/periodic-smoke-tests.yml +30 -0
- package/.github/workflows/publish-mcp.yml +4 -4
- package/{alerts.test.js → alerts.test.ts} +17 -29
- package/{alerts.js → alerts.ts} +10 -13
- package/api/{getReport.test.js → getReport.test.ts} +9 -10
- package/api/{getReport.js → getReport.ts} +23 -24
- package/api/{listDiagnoses.test.js → listDiagnoses.test.ts} +26 -27
- package/api/{listDiagnoses.js → listDiagnoses.ts} +46 -70
- package/api/{listIssues.test.js → listIssues.test.ts} +21 -14
- package/api/{listIssues.js → listIssues.ts} +75 -74
- package/api/{listReports.test.js → listReports.test.ts} +17 -16
- package/api/listReports.ts +87 -0
- package/api/{listViolators.test.js → listViolators.test.ts} +19 -20
- package/api/{listViolators.js → listViolators.ts} +45 -70
- package/api/requestFeature.test.ts +54 -0
- package/api/{requestFeature.js → requestFeature.ts} +18 -15
- package/api/requestRetest.test.ts +144 -0
- package/api/requestRetest.ts +119 -0
- package/api/requestTest.test.ts +153 -0
- package/api/requestTest.ts +123 -0
- package/api/{routes.test.js → routes.test.ts} +5 -5
- package/api/{routes.js → routes.ts} +6 -6
- package/api/{schemas.js → schemas.ts} +69 -50
- package/api/{util.test.js → util.test.ts} +35 -14
- package/api/{util.js → util.ts} +54 -51
- package/api/version.ts +8 -0
- package/{apiIntegration.test.js → apiIntegration.test.ts} +12 -13
- package/balances.ts +87 -0
- package/{checkinfo.js → checkinfo.ts} +24 -20
- package/docs/AI-TOOLS.md +4 -4
- package/docs/BACKLOG.md +360 -0
- package/docs/SERVICE.md +170 -17
- package/docs/decisions/002-coverage-tool.md +40 -0
- package/docs/decisions/003-adr-threshold.md +34 -0
- package/docs/decisions/004-docs-directory.md +23 -0
- package/docs/decisions/005-observability.md +28 -0
- package/docs/decisions/006-git-workflow.md +28 -0
- package/docs/decisions/007-internal-testing.md +34 -0
- package/docs/decisions/008-dependencies.md +28 -0
- package/docs/decisions/009-qai-integration.md +40 -0
- package/docs/decisions/QAI-HISTORICAL-CONTEXT.md +42 -0
- package/eslint.config.mjs +27 -0
- package/index.html +3 -3
- package/{index.test.js → index.test.ts} +397 -152
- package/{index.js → index.ts} +386 -285
- package/{indexBalances.test.js → indexBalances.test.ts} +21 -17
- package/{indexServe.test.js → indexServe.test.ts} +9 -9
- package/linkValidation.test.ts +190 -0
- package/{mcp.test.js → mcp.test.ts} +53 -26
- package/{mcp.js → mcp.ts} +29 -20
- package/memory/MEMORY.md +7 -0
- package/memory/user_maintainer_context.md +24 -0
- package/openapi.yaml +115 -68
- package/package.json +35 -15
- package/pm2.config.cjs +29 -0
- package/{routes.test.js → routes.test.ts} +8 -7
- package/{routing.test.js → routing.test.ts} +11 -12
- package/scripts/{generate-openapi.js → generate-openapi.ts} +13 -13
- package/serve.ts +15 -0
- package/smokeTest.ts +234 -0
- package/test/dbFixture.ts +21 -0
- package/test/fixtures/{buildFixtures.js → buildFixtures.ts} +14 -13
- package/test/{setup.cjs → setup.ts} +2 -2
- package/tsconfig.json +16 -0
- package/util.test.ts +1129 -0
- package/util.ts +1017 -0
- package/web/ai0BalanceForm/{ai0BalanceForm.test.js → ai0BalanceForm.test.ts} +15 -15
- package/web/ai0BalanceForm/{index.js → index.ts} +10 -18
- package/web/enqueue/index.ts +56 -0
- package/web/enqueueForm/index.html +9 -9
- package/web/enqueueForm/index.ts +43 -0
- package/web/expungeReportsForm/{expungeReportsForm.test.js → expungeReportsForm.test.ts} +18 -20
- package/web/expungeReportsForm/index.ts +18 -0
- package/web/hideReportForm/{hideReportForm.test.js → hideReportForm.test.ts} +14 -14
- package/web/hideReportForm/{index.js → index.ts} +17 -21
- package/web/listDiagnoses/{index.js → index.ts} +48 -58
- package/web/listDiagnoses/{listDiagnoses.test.js → listDiagnoses.test.ts} +39 -34
- package/web/listIssues/{index.js → index.ts} +67 -92
- package/web/listIssues/{listIssues.test.js → listIssues.test.ts} +45 -51
- package/web/listReports/index.html +3 -3
- package/web/listReports/{index.js → index.ts} +74 -58
- package/web/listReports/{listReports.test.js → listReports.test.ts} +41 -43
- package/web/listRules/{index.js → index.ts} +18 -23
- package/web/listRules/{listRules.test.js → listRules.test.ts} +26 -19
- package/web/listTopIssues/{index.js → index.ts} +36 -47
- package/web/listTopIssues/{listTopIssues.test.js → listTopIssues.test.ts} +26 -17
- package/web/listViolators/{index.js → index.ts} +51 -71
- package/web/listViolators/{listViolators.test.js → listViolators.test.ts} +43 -36
- package/web/manage/index.ts +22 -0
- package/web/pruneReportsForm/index.ts +18 -0
- package/web/pruneReportsForm/{pruneReportsForm.test.js → pruneReportsForm.test.ts} +11 -11
- package/web/reannotate/index.ts +71 -0
- package/web/reannotate/{reannotate.test.js → reannotate.test.ts} +19 -22
- package/web/reannotateForm/{index.js → index.ts} +41 -50
- package/web/reannotateForm/{reannotateForm.test.js → reannotateForm.test.ts} +13 -16
- package/web/renewWCAG/{index.js → index.ts} +9 -9
- package/web/renewWCAG/{renewWCAG.test.js → renewWCAG.test.ts} +17 -20
- package/web/renewWCAGForm/index.ts +22 -0
- package/web/{expungeReportsForm/index.js → reportDeletion.ts} +57 -39
- package/web/requestRetest/index.html +7 -7
- package/web/requestRetest/index.ts +62 -0
- package/web/requestRetest/requestRetest.test.ts +130 -0
- package/web/requestRetestForm/{index.js → index.ts} +10 -16
- package/web/requestRetestForm/{requestRetestForm.test.js → requestRetestForm.test.ts} +11 -12
- package/web/requestTest/index.html +7 -7
- package/web/requestTest/index.ts +52 -0
- package/web/requestTest/{requestTest.test.js → requestTest.test.ts} +39 -24
- package/web/requestTestForm/index.html +4 -4
- package/web/requestTestForm/index.ts +21 -0
- package/web/requestTestForm/{requestTestForm.test.js → requestTestForm.test.ts} +9 -9
- package/web/rewindReportsForm/index.ts +18 -0
- package/web/rewindReportsForm/{rewindReportsForm.test.js → rewindReportsForm.test.ts} +11 -11
- package/web/tutorialAI/index.html +184 -0
- package/web/tutorialAI/index.ts +80 -0
- package/web/tutorialAI/tutorialAI.test.ts +192 -0
- package/web/{tutorial → tutorialWeb}/index.html +9 -8
- package/web/tutorialWeb/index.ts +78 -0
- package/web/tutorialWeb/tutorialWeb.test.ts +195 -0
- package/web/unhideReportForm/{index.js → index.ts} +18 -22
- package/web/unhideReportForm/{unhideReportForm.test.js → unhideReportForm.test.ts} +16 -16
- package/aceconfig.js +0 -28
- package/api/listReports.js +0 -89
- package/api/requestFeature.test.js +0 -58
- package/api/requestRetest.js +0 -96
- package/api/requestRetest.test.js +0 -80
- package/api/requestTest.js +0 -106
- package/api/requestTest.test.js +0 -92
- package/api/version.js +0 -8
- package/indexFallback.test.js +0 -33
- package/indexWorkers.test.js +0 -79
- package/jsconfig.json +0 -8
- package/pm2.config.js +0 -18
- package/smokeTest.js +0 -128
- package/util.js +0 -744
- package/util.test.js +0 -968
- package/web/enqueue/index.js +0 -67
- package/web/enqueueForm/index.js +0 -49
- package/web/manage/index.js +0 -22
- package/web/pruneReportsForm/index.js +0 -123
- package/web/reannotate/index.js +0 -58
- package/web/renewWCAGForm/index.js +0 -22
- package/web/requestRetest/index.js +0 -31
- package/web/requestRetest/requestRetest.test.js +0 -119
- package/web/requestTest/index.js +0 -24
- package/web/requestTestForm/index.js +0 -21
- package/web/rewindReportsForm/index.js +0 -121
- package/web/tutorial/index.js +0 -63
- package/web/tutorial/tutorial.test.js +0 -97
- /package/test/fixtures/db/jobs/{recs.json → testRequests.json} +0 -0
- /package/web/{tutorial → tutorialWeb}/images/newsletter-form.png +0 -0
package/{index.js → index.ts}
RENAMED
|
@@ -1,70 +1,165 @@
|
|
|
1
1
|
/*
|
|
2
|
-
index.
|
|
2
|
+
index.ts
|
|
3
3
|
Manages Kilotest.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
// ENVIRONMENT
|
|
7
|
-
|
|
8
|
-
require('dotenv').config({quiet: true});
|
|
9
|
-
|
|
10
6
|
// IMPORTS
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
|
|
8
|
+
import dotenv from 'dotenv';
|
|
9
|
+
import {
|
|
10
|
+
annotateReportObject,
|
|
13
11
|
createLock,
|
|
12
|
+
deleteTestRequests,
|
|
13
|
+
errorMessage,
|
|
14
14
|
getJobNames,
|
|
15
15
|
getJSON,
|
|
16
16
|
getObject,
|
|
17
17
|
getPOSTData,
|
|
18
18
|
getReport,
|
|
19
|
-
|
|
19
|
+
isReportError,
|
|
20
20
|
getReportPath,
|
|
21
21
|
hiddenReportsPath,
|
|
22
|
-
isHidden,
|
|
23
22
|
isReportAvailable,
|
|
24
23
|
isTimeStamp,
|
|
25
24
|
isJobID,
|
|
25
|
+
isUsableReport,
|
|
26
26
|
jobsPath,
|
|
27
|
-
recsLock,
|
|
28
27
|
reportsPath
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
28
|
+
} from './util.ts';
|
|
29
|
+
import {sendAlert} from './alerts.ts';
|
|
30
|
+
import {checkBalancesForAlerts} from './balances.ts';
|
|
31
|
+
import type {Report} from 'testaro';
|
|
32
|
+
import {handleMCP, mcpPath} from './mcp.ts';
|
|
33
|
+
import fs from 'node:fs/promises';
|
|
34
|
+
import {handleComment as handleTutorialWebComment} from './web/tutorialWeb/index.ts';
|
|
35
|
+
import {answer as tutorialWeb} from './web/tutorialWeb/index.ts';
|
|
36
|
+
import {answer as tutorialAI, handleComment as handleTutorialAIComment} from './web/tutorialAI/index.ts';
|
|
37
|
+
import http, {type IncomingMessage, type ServerResponse} from 'node:http';
|
|
38
|
+
import https from 'node:https';
|
|
39
|
+
import path from 'node:path';
|
|
40
|
+
import {answer as ai0BalanceForm} from './web/ai0BalanceForm/index.ts';
|
|
41
|
+
import {answer as enqueue} from './web/enqueue/index.ts';
|
|
42
|
+
import {answer as enqueueForm} from './web/enqueueForm/index.ts';
|
|
43
|
+
import {answer as expungeReportsForm} from './web/expungeReportsForm/index.ts';
|
|
44
|
+
import {answer as hideReportForm} from './web/hideReportForm/index.ts';
|
|
45
|
+
import {answer as listDiagnosesPage} from './web/listDiagnoses/index.ts';
|
|
46
|
+
import {answer as listIssuesPage} from './web/listIssues/index.ts';
|
|
47
|
+
import {answer as listReportsPage} from './web/listReports/index.ts';
|
|
48
|
+
import {answer as listRules} from './web/listRules/index.ts';
|
|
49
|
+
import {answer as listTopIssues} from './web/listTopIssues/index.ts';
|
|
50
|
+
import {answer as listViolatorsPage} from './web/listViolators/index.ts';
|
|
51
|
+
import {answer as manage} from './web/manage/index.ts';
|
|
52
|
+
import {answer as pruneReportsForm} from './web/pruneReportsForm/index.ts';
|
|
53
|
+
import {answer as reannotate} from './web/reannotate/index.ts';
|
|
54
|
+
import {answer as reannotateForm} from './web/reannotateForm/index.ts';
|
|
55
|
+
import {answer as renewWCAG} from './web/renewWCAG/index.ts';
|
|
56
|
+
import {answer as renewWCAGForm} from './web/renewWCAGForm/index.ts';
|
|
57
|
+
import {answer as requestRetestPage} from './web/requestRetest/index.ts';
|
|
58
|
+
import {answer as requestRetestForm} from './web/requestRetestForm/index.ts';
|
|
59
|
+
import {answer as requestTestPage} from './web/requestTest/index.ts';
|
|
60
|
+
import {answer as requestTestForm} from './web/requestTestForm/index.ts';
|
|
61
|
+
import {answer as rewindReportsForm} from './web/rewindReportsForm/index.ts';
|
|
62
|
+
import {answer as unhideReportForm} from './web/unhideReportForm/index.ts';
|
|
63
|
+
import {response as getReportAPI} from './api/getReport.ts';
|
|
64
|
+
import {response as listDiagnosesAPI} from './api/listDiagnoses.ts';
|
|
65
|
+
import {response as listIssuesAPI} from './api/listIssues.ts';
|
|
66
|
+
import {response as listReportsAPI} from './api/listReports.ts';
|
|
67
|
+
import {response as listViolatorsAPI} from './api/listViolators.ts';
|
|
68
|
+
import {response as requestFeatureAPI} from './api/requestFeature.ts';
|
|
69
|
+
import {response as requestRetestAPI} from './api/requestRetest.ts';
|
|
70
|
+
import {response as requestTestAPI} from './api/requestTest.ts';
|
|
71
|
+
|
|
72
|
+
// ENVIRONMENT
|
|
73
|
+
|
|
74
|
+
dotenv.config({quiet: true});
|
|
75
|
+
|
|
76
|
+
// CONSTANTS
|
|
77
|
+
|
|
78
|
+
// The data returned by a page-answering handler.
|
|
79
|
+
type AnswerData = {status: string; message?: string; answerPage?: string};
|
|
80
|
+
// A page-answering handler, whose parameters vary by topic.
|
|
81
|
+
type PageHandler = (...args: any[]) => Promise<AnswerData>;
|
|
82
|
+
|
|
83
|
+
const answer: {
|
|
84
|
+
ai0BalanceForm: PageHandler;
|
|
85
|
+
enqueue: PageHandler;
|
|
86
|
+
enqueueForm: PageHandler;
|
|
87
|
+
expungeReportsForm: PageHandler;
|
|
88
|
+
hideReportForm: PageHandler;
|
|
89
|
+
listDiagnoses: PageHandler;
|
|
90
|
+
listIssues: PageHandler;
|
|
91
|
+
listReports: PageHandler;
|
|
92
|
+
listRules: PageHandler;
|
|
93
|
+
listTopIssues: PageHandler;
|
|
94
|
+
listViolators: PageHandler;
|
|
95
|
+
manage: PageHandler;
|
|
96
|
+
pruneReportsForm: PageHandler;
|
|
97
|
+
tutorialWeb: PageHandler;
|
|
98
|
+
tutorialAI: PageHandler;
|
|
99
|
+
reannotate: PageHandler;
|
|
100
|
+
reannotateForm: PageHandler;
|
|
101
|
+
renewWCAG: PageHandler;
|
|
102
|
+
renewWCAGForm: PageHandler;
|
|
103
|
+
requestRetest: PageHandler;
|
|
104
|
+
requestRetestForm: PageHandler;
|
|
105
|
+
requestTest: PageHandler;
|
|
106
|
+
requestTestForm: PageHandler;
|
|
107
|
+
rewindReportsForm: PageHandler;
|
|
108
|
+
unhideReportForm: PageHandler;
|
|
109
|
+
[key: string]: PageHandler | undefined;
|
|
110
|
+
} = {
|
|
111
|
+
ai0BalanceForm,
|
|
112
|
+
enqueue,
|
|
113
|
+
enqueueForm,
|
|
114
|
+
expungeReportsForm,
|
|
115
|
+
hideReportForm,
|
|
116
|
+
listDiagnoses: listDiagnosesPage,
|
|
117
|
+
listIssues: listIssuesPage,
|
|
118
|
+
listReports: listReportsPage,
|
|
119
|
+
listRules,
|
|
120
|
+
listTopIssues,
|
|
121
|
+
listViolators: listViolatorsPage,
|
|
122
|
+
manage,
|
|
123
|
+
pruneReportsForm,
|
|
124
|
+
tutorialWeb,
|
|
125
|
+
tutorialAI,
|
|
126
|
+
reannotate,
|
|
127
|
+
reannotateForm,
|
|
128
|
+
renewWCAG,
|
|
129
|
+
renewWCAGForm,
|
|
130
|
+
requestRetest: requestRetestPage,
|
|
131
|
+
requestRetestForm,
|
|
132
|
+
requestTest: requestTestPage,
|
|
133
|
+
requestTestForm,
|
|
134
|
+
rewindReportsForm,
|
|
135
|
+
unhideReportForm
|
|
136
|
+
};
|
|
137
|
+
// Response functions of the API services.
|
|
138
|
+
type ApiResponder = (args: string[]) => Promise<unknown>;
|
|
139
|
+
const apiRespond: {
|
|
140
|
+
getReport: ApiResponder;
|
|
141
|
+
listDiagnoses: ApiResponder;
|
|
142
|
+
listIssues: ApiResponder;
|
|
143
|
+
listReports: ApiResponder;
|
|
144
|
+
listViolators: ApiResponder;
|
|
145
|
+
requestFeature: ApiResponder;
|
|
146
|
+
requestRetest: ApiResponder;
|
|
147
|
+
requestTest: ApiResponder;
|
|
148
|
+
} = {
|
|
149
|
+
getReport: getReportAPI,
|
|
150
|
+
listDiagnoses: listDiagnosesAPI,
|
|
151
|
+
listIssues: listIssuesAPI,
|
|
152
|
+
listReports: listReportsAPI,
|
|
153
|
+
listViolators: listViolatorsAPI,
|
|
154
|
+
requestFeature: requestFeatureAPI,
|
|
155
|
+
requestRetest: requestRetestAPI,
|
|
156
|
+
requestTest: requestTestAPI
|
|
62
157
|
};
|
|
63
158
|
|
|
64
159
|
// CONSTANTS
|
|
65
160
|
|
|
66
161
|
// Paths that the application is authorized to handle, by method, as glob-style patterns where * matches any sequence of characters.
|
|
67
|
-
const routes =
|
|
162
|
+
export const routes = {
|
|
68
163
|
GET: [
|
|
69
164
|
'*.html*',
|
|
70
165
|
'/',
|
|
@@ -79,46 +174,31 @@ const routes = exports.routes = {
|
|
|
79
174
|
'/mcp',
|
|
80
175
|
'/openapi.json',
|
|
81
176
|
'/openapi.yaml',
|
|
177
|
+
'/qai',
|
|
178
|
+
'/qai/comments',
|
|
82
179
|
'/robots.txt',
|
|
83
180
|
'/sitemap.xml',
|
|
84
181
|
'/style.css',
|
|
85
182
|
'/swagger.json',
|
|
86
183
|
'/swagger.yaml',
|
|
87
|
-
'/
|
|
184
|
+
'/tutorialWeb/images/*',
|
|
185
|
+
'/tutorialAI/images/*'
|
|
88
186
|
],
|
|
89
187
|
POST: [
|
|
90
188
|
'/api/*',
|
|
91
189
|
'/mcp',
|
|
190
|
+
'/tutorialAIComment.html',
|
|
92
191
|
'/reannotate.html',
|
|
93
|
-
'/
|
|
192
|
+
'/requestAction.html',
|
|
94
193
|
'/renewWCAG.html',
|
|
95
194
|
'/requestRetest.html/*',
|
|
96
195
|
'/requestTest.html',
|
|
97
|
-
'/
|
|
196
|
+
'/tutorialWebComment.html',
|
|
98
197
|
'/worker/job',
|
|
99
198
|
'/worker/report'
|
|
100
199
|
]
|
|
101
200
|
};
|
|
102
|
-
const protocol = process.env.PROTOCOL || 'http';
|
|
103
|
-
// Credentials of the Testaro workers, by worker ID, from a JSON-object environment variable.
|
|
104
|
-
// Each worker ID maps to a secret (used only to authenticate the worker, never published) and a
|
|
105
|
-
// name (a non-secret label safe to publish, e.g. in report data and logs).
|
|
106
|
-
const workerCredentials = (() => {
|
|
107
|
-
try {
|
|
108
|
-
return JSON.parse(process.env.TESTARO_WORKERS || '{}');
|
|
109
|
-
}
|
|
110
|
-
catch (error) {
|
|
111
|
-
console.error(`ERROR: TESTARO_WORKERS is not valid JSON (${error.message})`);
|
|
112
|
-
return {};
|
|
113
|
-
}
|
|
114
|
-
})();
|
|
115
|
-
// Values that may require alerts.
|
|
116
|
-
const balancePath = path.join(__dirname, 'ai0Balance.json');
|
|
117
201
|
const jobLock = createLock();
|
|
118
|
-
const WAVE_THRESHOLD = Number(process.env.WAVE_BALANCE_THRESHOLD);
|
|
119
|
-
const AI_SERVICE0_THRESHOLD = Number(process.env.AI_SERVICE0_BALANCE_THRESHOLD);
|
|
120
|
-
const AI_MODEL0_INPUT_PRICE = Number(process.env.AI_MODEL0_INPUT_PRICE);
|
|
121
|
-
const AI_MODEL0_OUTPUT_PRICE = Number(process.env.AI_MODEL0_OUTPUT_PRICE);
|
|
122
202
|
|
|
123
203
|
// FUNCTIONS
|
|
124
204
|
|
|
@@ -126,19 +206,19 @@ const queuePath = () => path.join(jobsPath(), 'queue');
|
|
|
126
206
|
const claimedPath = () => path.join(jobsPath(), 'claimed');
|
|
127
207
|
const failedPath = () => path.join(jobsPath(), 'failed');
|
|
128
208
|
// Returns whether a pathname matches a glob-style pattern.
|
|
129
|
-
const matchPath = (pattern, pathname) => {
|
|
209
|
+
const matchPath = (pattern: string, pathname: string) => {
|
|
130
210
|
const regex = new RegExp(
|
|
131
211
|
'^' + pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$'
|
|
132
212
|
);
|
|
133
213
|
return regex.test(pathname);
|
|
134
214
|
};
|
|
135
215
|
// Returns whether a pathname is authorized for a method.
|
|
136
|
-
const isPathAllowed =
|
|
137
|
-
const patterns = routes[method] || [];
|
|
216
|
+
export const isPathAllowed = (method: string, pathname: string): boolean => {
|
|
217
|
+
const patterns = (routes as Record<string, string[]>)[method] || [];
|
|
138
218
|
return patterns.some(pattern => matchPath(pattern, pathname));
|
|
139
219
|
};
|
|
140
220
|
// Serves or sends an error message.
|
|
141
|
-
const serveError =
|
|
221
|
+
export const serveError = async (error: Record<string, unknown>, response: ServerResponse, isHumanUser = true, statusCode = 400): Promise<void> => {
|
|
142
222
|
const errorLines = Object.entries(error).map(pair => `${pair[0]}: ${pair[1]}`);
|
|
143
223
|
const errorSummary = errorLines.join('\n') || 'ERROR';
|
|
144
224
|
console.log(errorSummary);
|
|
@@ -152,7 +232,7 @@ const serveError = exports.serveError = async (error, response, isHumanUser = tr
|
|
|
152
232
|
response.setHeader('Access-Control-Allow-Origin', '*');
|
|
153
233
|
response.setHeader('Cache-Control', 'public, max-age=300, stale-while-revalidate=3000');
|
|
154
234
|
const errorTemplate = await fs.readFile('error.html', 'utf8');
|
|
155
|
-
const errorMessage = error.message
|
|
235
|
+
const errorMessage = typeof error.message === 'string' ? error.message : 'ERROR';
|
|
156
236
|
const errorPage = errorTemplate.replace(/__error__/, errorMessage);
|
|
157
237
|
response.end(errorPage);
|
|
158
238
|
}
|
|
@@ -167,68 +247,8 @@ const serveError = exports.serveError = async (error, response, isHumanUser = tr
|
|
|
167
247
|
console.log('Cannot send error response because the response has ended.');
|
|
168
248
|
}
|
|
169
249
|
};
|
|
170
|
-
// Checks a report for balances nearing exhaustion.
|
|
171
|
-
const checkBalancesForAlerts = async report => {
|
|
172
|
-
// If the variables to be monitored for alerts are defined:
|
|
173
|
-
if (WAVE_THRESHOLD && AI_SERVICE0_THRESHOLD && AI_MODEL0_INPUT_PRICE && AI_MODEL0_OUTPUT_PRICE) {
|
|
174
|
-
// WAVE.
|
|
175
|
-
const waveAct = report.acts.find(act => act.type === 'test' && act.which === 'wave');
|
|
176
|
-
const creditsRemaining = waveAct?.data?.creditsRemaining;
|
|
177
|
-
// If a WAVE balance nearing exhaustion is reported:
|
|
178
|
-
if (typeof creditsRemaining === 'number' && creditsRemaining < WAVE_THRESHOLD) {
|
|
179
|
-
// Alert a manager.
|
|
180
|
-
await sendAlert(
|
|
181
|
-
'Kilotest: WAVE balance low',
|
|
182
|
-
`Only ${creditsRemaining} WAVE credits remain (3 used per job)`
|
|
183
|
-
);
|
|
184
|
-
}
|
|
185
|
-
const testaroAct = report.acts.find(act => act.type === 'test' && act.which === 'testaro');
|
|
186
|
-
// Get the AI model token usage for the testaro allCaps test.
|
|
187
|
-
const usage = testaroAct?.data?.ruleData?.allCaps?.aiModelUsage;
|
|
188
|
-
let balanceJSON = null;
|
|
189
|
-
try {
|
|
190
|
-
// Get the recorded AI service 0 balance.
|
|
191
|
-
balanceJSON = await fs.readFile(balancePath, 'utf8');
|
|
192
|
-
}
|
|
193
|
-
catch {
|
|
194
|
-
console.error('ERROR: AI service 0 balance file missing');
|
|
195
|
-
}
|
|
196
|
-
// If the variables required for an AI service 0 balance alert are defined:
|
|
197
|
-
if (usage && AI_MODEL0_INPUT_PRICE && AI_MODEL0_OUTPUT_PRICE && balanceJSON) {
|
|
198
|
-
const inputCost = AI_MODEL0_INPUT_PRICE * usage.inputTokens;
|
|
199
|
-
const outputCost = AI_MODEL0_OUTPUT_PRICE * usage.outputTokens;
|
|
200
|
-
const cost = inputCost + outputCost;
|
|
201
|
-
// If any cost was incurred:
|
|
202
|
-
if (cost > 0) {
|
|
203
|
-
// Warn about this.
|
|
204
|
-
console.log(
|
|
205
|
-
'This job has made the production AI service 0 balance record wrong. Update it.'
|
|
206
|
-
);
|
|
207
|
-
}
|
|
208
|
-
try {
|
|
209
|
-
const balanceData = JSON.parse(balanceJSON);
|
|
210
|
-
// Get an estimate of the balance after this job.
|
|
211
|
-
const newBalance = balanceData.balance - cost;
|
|
212
|
-
// Update the recorded balance.
|
|
213
|
-
await fs.writeFile(balancePath, getJSON({balance: newBalance}));
|
|
214
|
-
console.log(`Estimated new AI Service 0 balance: $${newBalance.toFixed(2)}`);
|
|
215
|
-
// If it is nearing exhaustion:
|
|
216
|
-
if (newBalance < AI_SERVICE0_THRESHOLD) {
|
|
217
|
-
// Alert a manager.
|
|
218
|
-
await sendAlert(
|
|
219
|
-
'Kilotest: AI service 0 balance low',
|
|
220
|
-
`Balance of AI service 0 account (https://console.anthropic.com) only about $${newBalance.toFixed(2)} (about $0.01 used per job)`
|
|
221
|
-
);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
catch (error) {
|
|
225
|
-
console.log(`ERROR managing AI service 0 balance: ${error.message}`);
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
};
|
|
230
250
|
// Creates an error object about a suspicious request.
|
|
231
|
-
const getAbuseError =
|
|
251
|
+
export const getAbuseError = (request: IncomingMessage, reason: string | undefined) => {
|
|
232
252
|
const {method, url, headers} = request;
|
|
233
253
|
const forwardedFor = headers['x-forwarded-for'];
|
|
234
254
|
const remoteAddress = request.socket.remoteAddress;
|
|
@@ -246,23 +266,36 @@ const getAbuseError = exports.getAbuseError = (request, reason) => {
|
|
|
246
266
|
};
|
|
247
267
|
// Gets the ID and secret from a request's HTTP Basic Authorization header, or null if the header
|
|
248
268
|
// is absent or malformed.
|
|
249
|
-
const getBasicAuth = request => {
|
|
269
|
+
const getBasicAuth = (request: IncomingMessage) => {
|
|
250
270
|
const header = request.headers['authorization'] || '';
|
|
251
271
|
const match = header.match(/^Basic\s+(\S+)$/i);
|
|
252
272
|
if (!match) {
|
|
253
273
|
return null;
|
|
254
274
|
}
|
|
255
|
-
const decoded = Buffer.from(match[1]
|
|
275
|
+
const decoded = Buffer.from(match[1]!, 'base64').toString('utf8');
|
|
256
276
|
const sepIndex = decoded.indexOf(':');
|
|
257
277
|
if (sepIndex === -1) {
|
|
258
278
|
return null;
|
|
259
279
|
}
|
|
260
280
|
return {id: decoded.slice(0, sepIndex), secret: decoded.slice(sepIndex + 1)};
|
|
261
281
|
};
|
|
282
|
+
// Returns the credentials of the Testaro workers, by worker ID, from a JSON-object
|
|
283
|
+
// environment variable. Each worker ID maps to a secret (used only to authenticate the worker,
|
|
284
|
+
// never published) and a name (a non-secret label safe to publish, e.g. in report data and logs).
|
|
285
|
+
const getWorkerCredentials = () => {
|
|
286
|
+
try {
|
|
287
|
+
return JSON.parse(process.env.TESTARO_WORKERS || '{}');
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
console.error(`ERROR: TESTARO_WORKERS is not valid JSON (${errorMessage(error)})`);
|
|
291
|
+
return {};
|
|
292
|
+
}
|
|
293
|
+
};
|
|
262
294
|
// Gets the published name of a Testaro worker or null if not authenticated.
|
|
263
|
-
const getAuthorizedWorkerName = request => {
|
|
295
|
+
const getAuthorizedWorkerName = (request: IncomingMessage) => {
|
|
264
296
|
const credentials = getBasicAuth(request);
|
|
265
297
|
if (credentials) {
|
|
298
|
+
const workerCredentials = getWorkerCredentials();
|
|
266
299
|
const worker = workerCredentials[credentials.id];
|
|
267
300
|
if (worker && worker.secret === credentials.secret && worker.name) {
|
|
268
301
|
return worker.name;
|
|
@@ -271,7 +304,7 @@ const getAuthorizedWorkerName = request => {
|
|
|
271
304
|
return null;
|
|
272
305
|
};
|
|
273
306
|
// Processes a job request from a Testaro worker.
|
|
274
|
-
const processJobRequest = async (request, response, workerName) => jobLock(async () => {
|
|
307
|
+
const processJobRequest = async (request: IncomingMessage, response: ServerResponse, workerName: string) => jobLock(async () => {
|
|
275
308
|
let clean = true;
|
|
276
309
|
const messageStart = `Testaro worker ${workerName} requested a job, `;
|
|
277
310
|
const jobNames = await getJobNames();
|
|
@@ -279,7 +312,7 @@ const processJobRequest = async (request, response, workerName) => jobLock(async
|
|
|
279
312
|
// For each claimed job:
|
|
280
313
|
for (const jobName of claimedJobNames) {
|
|
281
314
|
const job = await getObject(path.join(jobsPath(), 'claimed', jobName));
|
|
282
|
-
const {id, sources} = job;
|
|
315
|
+
const {id, sources} = job as {id: string, sources: {worker: string}};
|
|
283
316
|
const {worker} = sources;
|
|
284
317
|
// If its assignee is the worker:
|
|
285
318
|
if (worker === workerName) {
|
|
@@ -300,9 +333,9 @@ const processJobRequest = async (request, response, workerName) => jobLock(async
|
|
|
300
333
|
const queuedJobNames = jobNames.queue;
|
|
301
334
|
// If any jobs are queued:
|
|
302
335
|
if (queuedJobNames.length) {
|
|
303
|
-
const oldestJobName = queuedJobNames[0]
|
|
336
|
+
const oldestJobName = queuedJobNames[0]!;
|
|
304
337
|
// Get the first one.
|
|
305
|
-
const firstJob = await getObject(path.join(queuePath(), oldestJobName));
|
|
338
|
+
const firstJob = await getObject(path.join(queuePath(), oldestJobName)) as {id: string, sources: {worker: string}, target: {what: string}};
|
|
306
339
|
// Add the public worker name to the job, in a property Testaro does not read or alter.
|
|
307
340
|
firstJob.sources.worker = workerName;
|
|
308
341
|
console.log(
|
|
@@ -336,15 +369,15 @@ const processJobRequest = async (request, response, workerName) => jobLock(async
|
|
|
336
369
|
}
|
|
337
370
|
});
|
|
338
371
|
// Handles a request.
|
|
339
|
-
const
|
|
372
|
+
const handleRequest = async (request: IncomingMessage, response: ServerResponse) => {
|
|
340
373
|
// Sets response headers.
|
|
341
|
-
const setHeaders = (contentType, location, volatility = 'high') => {
|
|
374
|
+
const setHeaders = (contentType: string, location: string | null, volatility: string = 'high') => {
|
|
342
375
|
response.setHeader('content-type', `${contentType}; charset=utf-8`);
|
|
343
376
|
if (location) {
|
|
344
377
|
response.setHeader('content-location', location);
|
|
345
378
|
}
|
|
346
379
|
response.setHeader('Access-Control-Allow-Origin', '*');
|
|
347
|
-
const lives = {
|
|
380
|
+
const lives: Record<string, number[]> = {
|
|
348
381
|
ultra: [3, 30],
|
|
349
382
|
high: [300, 3000],
|
|
350
383
|
medium: [1000, 10000],
|
|
@@ -352,13 +385,13 @@ const requestHandler = async (request, response) => {
|
|
|
352
385
|
};
|
|
353
386
|
response.setHeader(
|
|
354
387
|
'Cache-Control',
|
|
355
|
-
`public, max-age=${lives[volatility][0]}, stale-while-revalidate=${lives[volatility][1]}`
|
|
388
|
+
`public, max-age=${lives[volatility]![0]}, stale-while-revalidate=${lives[volatility]![1]}`
|
|
356
389
|
);
|
|
357
390
|
};
|
|
358
391
|
const {method, url} = request;
|
|
359
|
-
const requestURL = new URL(url, 'https://localhost:3000');
|
|
392
|
+
const requestURL = new URL(url as string, 'https://localhost:3000');
|
|
360
393
|
const {pathname, search} = requestURL;
|
|
361
|
-
const pageName = pathname.split('/')[1]
|
|
394
|
+
const pageName = pathname.split('/')[1]!;
|
|
362
395
|
const pathTail = pathname.split('/').slice(2).join('/');
|
|
363
396
|
// If the request is a smoke-test probe, respond perfunctorily without executing a handler.
|
|
364
397
|
if (request.headers['x-kilotest-smoke']) {
|
|
@@ -374,8 +407,19 @@ const requestHandler = async (request, response) => {
|
|
|
374
407
|
}
|
|
375
408
|
// If it is for the model context protocol server:
|
|
376
409
|
else if (pathname === mcpPath) {
|
|
377
|
-
//
|
|
378
|
-
|
|
410
|
+
// If the request does not identify itself as capable of an SSE stream: it is a human
|
|
411
|
+
// browsing to the endpoint directly, not an MCP client, so explain what the endpoint is
|
|
412
|
+
// for instead of handing it a bare protocol-level rejection.
|
|
413
|
+
if (!request.headers.accept?.includes('text/event-stream')) {
|
|
414
|
+
await serveError({
|
|
415
|
+
message: 'This endpoint is for AI agents, not for direct browsing. See the <a href="/tutorialAI.html">tutorial on connecting an AI platform to Kilotest</a>.'
|
|
416
|
+
}, response);
|
|
417
|
+
}
|
|
418
|
+
// Otherwise, i.e. if it identifies itself as capable of an SSE stream:
|
|
419
|
+
else {
|
|
420
|
+
// Handle the MCP request.
|
|
421
|
+
await handleMCP(request, response);
|
|
422
|
+
}
|
|
379
423
|
}
|
|
380
424
|
// Otherwise, if it is for the home page:
|
|
381
425
|
else if (['/', '/index.html'].includes(pathname)) {
|
|
@@ -405,6 +449,18 @@ const requestHandler = async (request, response) => {
|
|
|
405
449
|
response.writeHead(301, {Location: '/openapi.yaml'});
|
|
406
450
|
response.end();
|
|
407
451
|
}
|
|
452
|
+
// Otherwise, if it is for the old QAI comments path:
|
|
453
|
+
else if (pathname === '/qai/comments') {
|
|
454
|
+
// Redirect the client permanently to the new tutorial path (comments are now integrated).
|
|
455
|
+
response.writeHead(301, {Location: '/tutorialAI.html'});
|
|
456
|
+
response.end();
|
|
457
|
+
}
|
|
458
|
+
// Otherwise, if it is for the old QAI root path:
|
|
459
|
+
else if (pathname === '/qai') {
|
|
460
|
+
// Redirect the client permanently to the new tutorial path.
|
|
461
|
+
response.writeHead(301, {Location: '/tutorialAI.html'});
|
|
462
|
+
response.end();
|
|
463
|
+
}
|
|
408
464
|
// Otherwise, if it is for the large-language-model summary guide:
|
|
409
465
|
else if (pageName === 'llms.txt') {
|
|
410
466
|
const llms = await fs.readFile('llms.txt', 'utf8');
|
|
@@ -435,43 +491,37 @@ const requestHandler = async (request, response) => {
|
|
|
435
491
|
}
|
|
436
492
|
// Otherwise, if it is for a full report download:
|
|
437
493
|
else if (pageName === 'fullReport.json') {
|
|
438
|
-
const [timeStamp, jobID] = pathTail.split('/');
|
|
494
|
+
const [timeStamp, jobID] = pathTail.split('/') as [string, string, ...string[]];
|
|
439
495
|
// If the request is syntactically valid:
|
|
440
496
|
if (isTimeStamp(timeStamp) && isJobID(jobID)) {
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
//
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
497
|
+
// Get it.
|
|
498
|
+
const report = await getReport(timeStamp, jobID);
|
|
499
|
+
// If this failed:
|
|
500
|
+
if (isReportError(report)) {
|
|
501
|
+
// This is not necessarily abuse: the link may have been generated (e.g. by
|
|
502
|
+
// listIssues.html) before the report was pruned or rewound, or the stored report
|
|
503
|
+
// may have become unreadable or unusable, none of which is the requester's fault.
|
|
504
|
+
// So tell the requester only that the request was invalid, without accusing them,
|
|
505
|
+
// but alert a manager with the real reason, since a syntactically valid report ID
|
|
506
|
+
// should otherwise always be usable.
|
|
507
|
+
console.error(
|
|
508
|
+
`Full report ${timeStamp}-${jobID} requested but unavailable (${report.error})`
|
|
509
|
+
);
|
|
510
|
+
await sendAlert(
|
|
511
|
+
'Kilotest: requested full report unavailable',
|
|
512
|
+
`Full report ${timeStamp}-${jobID} was requested but could not be retrieved: ${report.error}`
|
|
450
513
|
);
|
|
514
|
+
await serveError({message: 'ERROR: Invalid request'}, response, true);
|
|
451
515
|
}
|
|
452
|
-
// Otherwise, i.e. if
|
|
516
|
+
// Otherwise, i.e. if it succeeded:
|
|
453
517
|
else {
|
|
454
|
-
//
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
response,
|
|
462
|
-
true
|
|
463
|
-
);
|
|
464
|
-
}
|
|
465
|
-
// Otherwise, i.e. if it succeeded:
|
|
466
|
-
else {
|
|
467
|
-
// Serve response headers for a JSON download.
|
|
468
|
-
setHeaders('application/json', null, 'low');
|
|
469
|
-
response.setHeader(
|
|
470
|
-
'content-disposition', `attachment; filename="${timeStamp}-${jobID}.json"`,
|
|
471
|
-
);
|
|
472
|
-
// Download the report.
|
|
473
|
-
response.end(getJSON(report));
|
|
474
|
-
}
|
|
518
|
+
// Serve response headers for a JSON download.
|
|
519
|
+
setHeaders('application/json', null, 'low');
|
|
520
|
+
response.setHeader(
|
|
521
|
+
'content-disposition', `attachment; filename="${timeStamp}-${jobID}.json"`,
|
|
522
|
+
);
|
|
523
|
+
// Download the report.
|
|
524
|
+
response.end(getJSON(report));
|
|
475
525
|
}
|
|
476
526
|
}
|
|
477
527
|
// Otherwise, i.e. if the request is syntactically invalid:
|
|
@@ -483,8 +533,10 @@ const requestHandler = async (request, response) => {
|
|
|
483
533
|
// Otherwise, if it is for an HTML page other than the home page:
|
|
484
534
|
else if (pageName.endsWith('.html')) {
|
|
485
535
|
const topic = pageName.slice(0, -5);
|
|
486
|
-
// If the page can be generated
|
|
487
|
-
|
|
536
|
+
// If the page can be generated and is not POST-only (a POST-only path also
|
|
537
|
+
// matches the '*.html*' GET pattern, but its handler expects POST's argument
|
|
538
|
+
// list and performs no GET-appropriate rendering):
|
|
539
|
+
if (answer[topic] && !isPathAllowed('POST', pathname)) {
|
|
488
540
|
setHeaders('text/html', pathname, 'ultra');
|
|
489
541
|
// Get the answer data.
|
|
490
542
|
const answerData = await answer[topic](pathTail, search);
|
|
@@ -511,8 +563,7 @@ const requestHandler = async (request, response) => {
|
|
|
511
563
|
// If the service lists the available reports:
|
|
512
564
|
if (service === 'listReports') {
|
|
513
565
|
// Get the response body.
|
|
514
|
-
const responseBody = await
|
|
515
|
-
.response(specs);
|
|
566
|
+
const responseBody = await apiRespond.listReports([]);
|
|
516
567
|
// Send it.
|
|
517
568
|
setHeaders('application/json', null, 'ultra');
|
|
518
569
|
response.end(JSON.stringify(responseBody));
|
|
@@ -520,8 +571,7 @@ const requestHandler = async (request, response) => {
|
|
|
520
571
|
// Otherwise, if the service lists the issues in a report:
|
|
521
572
|
else if (service === 'listIssues') {
|
|
522
573
|
// Get the response body.
|
|
523
|
-
const responseBody = await
|
|
524
|
-
.response(specs);
|
|
574
|
+
const responseBody = await apiRespond.listIssues(specs);
|
|
525
575
|
// Send it.
|
|
526
576
|
setHeaders('application/json', null, 'high');
|
|
527
577
|
response.end(JSON.stringify(responseBody));
|
|
@@ -529,8 +579,7 @@ const requestHandler = async (request, response) => {
|
|
|
529
579
|
// Otherwise, if the service lists the violators of an issue in a report:
|
|
530
580
|
else if (service === 'listViolators') {
|
|
531
581
|
// Get the response body.
|
|
532
|
-
const responseBody = await
|
|
533
|
-
.response(specs);
|
|
582
|
+
const responseBody = await apiRespond.listViolators(specs);
|
|
534
583
|
// Send it.
|
|
535
584
|
setHeaders('application/json', null, 'high');
|
|
536
585
|
response.end(JSON.stringify(responseBody));
|
|
@@ -538,8 +587,7 @@ const requestHandler = async (request, response) => {
|
|
|
538
587
|
// Otherwise, if the service lists the diagnoses of a violation of an issue in a report:
|
|
539
588
|
else if (service === 'listDiagnoses') {
|
|
540
589
|
// Get the response body.
|
|
541
|
-
const responseBody = await
|
|
542
|
-
.response(specs);
|
|
590
|
+
const responseBody = await apiRespond.listDiagnoses(specs);
|
|
543
591
|
// Send it.
|
|
544
592
|
setHeaders('application/json', null, 'high');
|
|
545
593
|
response.end(JSON.stringify(responseBody));
|
|
@@ -547,8 +595,7 @@ const requestHandler = async (request, response) => {
|
|
|
547
595
|
// Otherwise, if the service serves a report:
|
|
548
596
|
else if (service === 'getReport') {
|
|
549
597
|
// Get the response body.
|
|
550
|
-
const responseBody = await
|
|
551
|
-
.response(specs);
|
|
598
|
+
const responseBody = await apiRespond.getReport(specs);
|
|
552
599
|
// Send it.
|
|
553
600
|
setHeaders('application/json', null, 'low');
|
|
554
601
|
response.end(JSON.stringify(responseBody));
|
|
@@ -560,13 +607,22 @@ const requestHandler = async (request, response) => {
|
|
|
560
607
|
}
|
|
561
608
|
}
|
|
562
609
|
// Otherwise, if it is for a tutorial image:
|
|
563
|
-
else if (pathname.startsWith('/
|
|
564
|
-
|
|
565
|
-
|
|
610
|
+
else if (pathname.startsWith('/tutorialWeb/images/') || pathname.startsWith('/tutorialAI/images/')) {
|
|
611
|
+
let imgFile: string;
|
|
612
|
+
let tutorialDir: string;
|
|
613
|
+
if (pathname.startsWith('/tutorialWeb/images/')) {
|
|
614
|
+
imgFile = pathname.slice('/tutorialWeb/images/'.length);
|
|
615
|
+
tutorialDir = 'tutorialWeb';
|
|
616
|
+
}
|
|
617
|
+
else {
|
|
618
|
+
imgFile = pathname.slice('/tutorialAI/images/'.length);
|
|
619
|
+
tutorialDir = 'tutorialAI';
|
|
620
|
+
}
|
|
621
|
+
const imgPath = path.join(import.meta.dirname, 'web', tutorialDir, 'images', imgFile);
|
|
566
622
|
try {
|
|
567
623
|
const img = await fs.readFile(imgPath);
|
|
568
624
|
const ext = path.extname(imgFile).toLowerCase();
|
|
569
|
-
const mimeTypes = {
|
|
625
|
+
const mimeTypes: Record<string, string> = {
|
|
570
626
|
'.png': 'image/png',
|
|
571
627
|
'.jpg': 'image/jpeg',
|
|
572
628
|
'.jpeg': 'image/jpeg',
|
|
@@ -585,7 +641,7 @@ const requestHandler = async (request, response) => {
|
|
|
585
641
|
// Otherwise, if it is for the application icon:
|
|
586
642
|
else if (pathname.includes('favicon.')) {
|
|
587
643
|
// Get the site icon.
|
|
588
|
-
const icon = await fs.readFile(path.join(
|
|
644
|
+
const icon = await fs.readFile(path.join(import.meta.dirname, 'favicon.ico'));
|
|
589
645
|
// Serve it.
|
|
590
646
|
setHeaders('image/x-icon', null, 'low');
|
|
591
647
|
response.write(icon, 'binary');
|
|
@@ -600,7 +656,7 @@ const requestHandler = async (request, response) => {
|
|
|
600
656
|
response.end(styleSheet);
|
|
601
657
|
}
|
|
602
658
|
catch (error) {
|
|
603
|
-
await serveError({message: error
|
|
659
|
+
await serveError({message: errorMessage(error)}, response, true);
|
|
604
660
|
}
|
|
605
661
|
}
|
|
606
662
|
// Otherwise, i.e. if it is any other GET request:
|
|
@@ -625,13 +681,17 @@ const requestHandler = async (request, response) => {
|
|
|
625
681
|
else {
|
|
626
682
|
// Get the data from the request body.
|
|
627
683
|
const postData = await getPOSTData(request);
|
|
628
|
-
// If the
|
|
629
|
-
if (
|
|
630
|
-
|
|
684
|
+
// If the body could not be parsed (unknown content-type or malformed JSON):
|
|
685
|
+
if (postData === null) {
|
|
686
|
+
await serveError({message: 'ERROR: Unreadable request body'}, response, true);
|
|
687
|
+
}
|
|
688
|
+
// If the request is a test request:
|
|
689
|
+
else if (pageName === 'requestTest.html') {
|
|
690
|
+
const {description, url, why} = postData as {description?: string; url: string; why?: string};
|
|
631
691
|
// If the request is valid:
|
|
632
|
-
if (
|
|
692
|
+
if (description && url.startsWith('https://') && why) {
|
|
633
693
|
// If a report on the page is already available:
|
|
634
|
-
if (await isReportAvailable(
|
|
694
|
+
if (await isReportAvailable(description, url)) {
|
|
635
695
|
// Report the error.
|
|
636
696
|
await serveError({message: 'ERROR: Page has already been tested'}, response, true);
|
|
637
697
|
}
|
|
@@ -640,8 +700,7 @@ const requestHandler = async (request, response) => {
|
|
|
640
700
|
// Serve headers for a response.
|
|
641
701
|
setHeaders('text/html', pathname, 'ultra');
|
|
642
702
|
// Get the answer data.
|
|
643
|
-
const answerData = await
|
|
644
|
-
.answer(what, url, why);
|
|
703
|
+
const answerData = await answer.requestTest(description, url, why);
|
|
645
704
|
// If they are valid:
|
|
646
705
|
if (answerData.status === 'ok') {
|
|
647
706
|
// Serve the answer page.
|
|
@@ -657,20 +716,19 @@ const requestHandler = async (request, response) => {
|
|
|
657
716
|
// Otherwise, i.e. if the request is invalid:
|
|
658
717
|
else {
|
|
659
718
|
// Report the error.
|
|
660
|
-
await serveError({message: 'ERROR: Invalid test
|
|
719
|
+
await serveError({message: 'ERROR: Invalid test request'}, response, true);
|
|
661
720
|
}
|
|
662
721
|
}
|
|
663
|
-
// Otherwise, if it is a retest
|
|
722
|
+
// Otherwise, if it is a retest request:
|
|
664
723
|
else if (pageName === 'requestRetest.html') {
|
|
665
|
-
const {why} = postData;
|
|
666
|
-
const [timeStamp, jobID] = pathTail.split('/');
|
|
724
|
+
const {why} = postData as {why?: string};
|
|
725
|
+
const [timeStamp, jobID] = pathTail.split('/') as [string, string, ...string[]];
|
|
667
726
|
// If the request is valid:
|
|
668
727
|
if (isTimeStamp(timeStamp) && isJobID(jobID) && why) {
|
|
669
728
|
// Serve response headers.
|
|
670
729
|
setHeaders('text/html', pathname, 'ultra');
|
|
671
730
|
// Get the answer data.
|
|
672
|
-
const answerData = await
|
|
673
|
-
.answer(pathTail, why);
|
|
731
|
+
const answerData = await answer.requestRetest(pathTail, why);
|
|
674
732
|
// If they are valid:
|
|
675
733
|
if (answerData.status === 'ok') {
|
|
676
734
|
// Serve the answer page.
|
|
@@ -685,24 +743,23 @@ const requestHandler = async (request, response) => {
|
|
|
685
743
|
// Otherwise, i.e. if the request is invalid:
|
|
686
744
|
else {
|
|
687
745
|
// Report the error.
|
|
688
|
-
await serveError({message: 'ERROR: Invalid retest
|
|
746
|
+
await serveError({message: 'ERROR: Invalid retest request'}, response, true);
|
|
689
747
|
}
|
|
690
748
|
}
|
|
691
749
|
// Otherwise, if it is an approval or rejection of a test request:
|
|
692
|
-
else if (pageName === '
|
|
693
|
-
const {target, authCode} = postData;
|
|
694
|
-
const [url,
|
|
750
|
+
else if (pageName === 'requestAction.html') {
|
|
751
|
+
const {target, authCode} = postData as {target: string; authCode?: string};
|
|
752
|
+
const [url, description] = target.split('\t') as [string, string];
|
|
695
753
|
// If the request is valid:
|
|
696
754
|
if (url.startsWith('https://') && authCode === process.env.AUTH_CODE) {
|
|
697
755
|
// Set the non-location headers for a response.
|
|
698
756
|
setHeaders('text/html', null, 'ultra');
|
|
699
757
|
// If the request is an approval:
|
|
700
|
-
if (
|
|
758
|
+
if (description) {
|
|
701
759
|
// Set a location header for a response.
|
|
702
760
|
response.setHeader('content-location', pathname);
|
|
703
|
-
// Process the approval and get the answer data about the remaining
|
|
704
|
-
const answerData = await
|
|
705
|
-
.answer(url, what, authCode);
|
|
761
|
+
// Process the approval and get the answer data about the remaining requests.
|
|
762
|
+
const answerData = await answer.enqueue(url, description, authCode);
|
|
706
763
|
// If the answer data are valid:
|
|
707
764
|
if (answerData.status === 'ok') {
|
|
708
765
|
// Serve the test-order page with the remaining recommendations.
|
|
@@ -716,20 +773,12 @@ const requestHandler = async (request, response) => {
|
|
|
716
773
|
}
|
|
717
774
|
// Otherwise, i.e. if it is a rejection:
|
|
718
775
|
else {
|
|
719
|
-
//
|
|
720
|
-
await
|
|
721
|
-
// Get the recommendations.
|
|
722
|
-
const recs = await getRecs();
|
|
723
|
-
// Delete the rejected URL.
|
|
724
|
-
delete recs[url];
|
|
725
|
-
// Save the revised recommendations.
|
|
726
|
-
await fs.writeFile(path.join(jobsPath(), 'recs.json'), getJSON(recs));
|
|
727
|
-
});
|
|
776
|
+
// Delete the test requests for the URL.
|
|
777
|
+
await deleteTestRequests(url);
|
|
728
778
|
// Set a location header for a response.
|
|
729
779
|
response.setHeader('content-location', '/enqueueForm.html');
|
|
730
780
|
// Get the answer data.
|
|
731
|
-
const answerData = await
|
|
732
|
-
.answer();
|
|
781
|
+
const answerData = await answer.enqueueForm();
|
|
733
782
|
// Serve the test-order form with the remaining recommendations.
|
|
734
783
|
response.end(answerData.answerPage);
|
|
735
784
|
}
|
|
@@ -742,12 +791,11 @@ const requestHandler = async (request, response) => {
|
|
|
742
791
|
}
|
|
743
792
|
// Otherwise, if it is a reannotation order:
|
|
744
793
|
else if (pageName === 'reannotate.html') {
|
|
745
|
-
const {authCode} = postData;
|
|
794
|
+
const {authCode} = postData as {authCode?: string};
|
|
746
795
|
// Set headers for a response.
|
|
747
796
|
setHeaders('text/html', pathname, 'ultra');
|
|
748
797
|
// Get the answer data.
|
|
749
|
-
const answerData = await
|
|
750
|
-
.answer(authCode);
|
|
798
|
+
const answerData = await answer.reannotate(authCode);
|
|
751
799
|
// If the answer data are valid:
|
|
752
800
|
if (answerData.status === 'ok') {
|
|
753
801
|
// Serve the answer page.
|
|
@@ -761,12 +809,11 @@ const requestHandler = async (request, response) => {
|
|
|
761
809
|
}
|
|
762
810
|
// Otherwise, if it is a WCAG map renewal:
|
|
763
811
|
else if (pageName === 'renewWCAG.html') {
|
|
764
|
-
const {authCode} = postData;
|
|
812
|
+
const {authCode} = postData as {authCode?: string};
|
|
765
813
|
// Set headers for a response.
|
|
766
814
|
setHeaders('text/html', pathname, 'low');
|
|
767
815
|
// Get the answer data.
|
|
768
|
-
const answerData = await
|
|
769
|
-
.answer(authCode);
|
|
816
|
+
const answerData = await answer.renewWCAG(authCode);
|
|
770
817
|
// If the answer data are valid:
|
|
771
818
|
if (answerData.status === 'ok') {
|
|
772
819
|
// Serve the answer page.
|
|
@@ -793,37 +840,68 @@ const requestHandler = async (request, response) => {
|
|
|
793
840
|
}
|
|
794
841
|
// Otherwise, if it is report acquisition:
|
|
795
842
|
else if (service === 'report') {
|
|
796
|
-
const {report} = postData;
|
|
797
|
-
const reportObj = report
|
|
843
|
+
const {report} = postData as {report?: Report};
|
|
844
|
+
const reportObj: Partial<Report> = report ?? {};
|
|
798
845
|
const {id, target} = reportObj;
|
|
799
|
-
const {what, url} = target
|
|
800
|
-
const [timeStamp, jobID] = id?.split('-') ?? ['', ''];
|
|
846
|
+
const {what: description, url} = (target ?? {}) as Partial<NonNullable<Report['target']>>;
|
|
847
|
+
const [timeStamp, jobID] = (id?.split('-') ?? ['', '']) as [string, string];
|
|
801
848
|
// If the request is syntactically valid:
|
|
802
|
-
if (id && isTimeStamp(timeStamp) && isJobID(jobID) &&
|
|
803
|
-
|
|
804
|
-
//
|
|
805
|
-
|
|
849
|
+
if (id && isTimeStamp(timeStamp) && isJobID(jobID) && description && url) {
|
|
850
|
+
// Get the job the report is from. A missing claimed-job file (the job was
|
|
851
|
+
// never claimed, or was already completed) is a normal outcome, handled below
|
|
852
|
+
// as if the job were not assigned to this worker; any other failure to read it
|
|
853
|
+
// should never occur and is left to propagate.
|
|
854
|
+
let claimedJob: unknown = null;
|
|
855
|
+
try {
|
|
856
|
+
claimedJob = await getObject(path.join(claimedPath(), `${id}.json`));
|
|
857
|
+
}
|
|
858
|
+
catch (error: unknown) {
|
|
859
|
+
if (!(error instanceof Error && (error.cause as NodeJS.ErrnoException | undefined)?.code === 'ENOENT')) {
|
|
860
|
+
throw error;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
806
863
|
// If the job was actually assigned to this worker:
|
|
807
|
-
if (typeof claimedJob === 'object' && claimedJob.sources?.worker === workerName) {
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
864
|
+
if (typeof claimedJob === 'object' && claimedJob !== null && (claimedJob as {sources?: {worker?: string}}).sources?.worker === workerName) {
|
|
865
|
+
// If the report is usable by Kilotest (as any report of an assigned job should be):
|
|
866
|
+
if (isUsableReport(reportObj)) {
|
|
867
|
+
console.log(`Testaro report ${id} was received from worker ${workerName}`);
|
|
868
|
+
// Add the public worker name to the report.
|
|
869
|
+
report!.sources = {...report!.sources, worker: workerName};
|
|
870
|
+
// Annotate the report before it is ever written, rather than writing it,
|
|
871
|
+
// reading it back, annotating that copy, and writing it again: the read-back
|
|
872
|
+
// could fail even though the just-written report is fine.
|
|
873
|
+
await annotateReportObject(reportObj);
|
|
874
|
+
// Save the annotated report.
|
|
875
|
+
await fs.writeFile(getReportPath(timeStamp, jobID), getJSON(report));
|
|
876
|
+
console.log(`Testaro report ${id} was annotated and saved`);
|
|
877
|
+
// Check the monetary balances and send alerts if nearing exhaustion.
|
|
878
|
+
await checkBalancesForAlerts(report!);
|
|
879
|
+
// Delete the job.
|
|
880
|
+
await fs.unlink(path.join(claimedPath(), `${id}.json`));
|
|
881
|
+
console.log(`Completed job ${id} deleted`);
|
|
882
|
+
// Acknowledge receipt.
|
|
883
|
+
response.setHeader('content-type', 'application/json; charset=utf-8');
|
|
884
|
+
response.end(JSON.stringify({status: 'ok'}));
|
|
885
|
+
console.log(
|
|
886
|
+
`Testaro report ${id} was received from Testaro worker ${workerName}`
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
// Otherwise, i.e. if the report is not usable by Kilotest:
|
|
890
|
+
else {
|
|
891
|
+
console.error(`ERROR: Report ${id} from worker ${workerName} is not usable`);
|
|
892
|
+
// Alert a manager, since an assigned job should never produce an unusable report.
|
|
893
|
+
await sendAlert(
|
|
894
|
+
'Kilotest: unusable report received',
|
|
895
|
+
`Job ${id} from worker ${workerName} produced a report that Kilotest cannot use. The job was reclassified as failed instead of being recorded.`
|
|
896
|
+
);
|
|
897
|
+
// Reclassify the job as failed, instead of recording the unusable report or
|
|
898
|
+
// leaving the job claimed indefinitely.
|
|
899
|
+
await fs.rename(
|
|
900
|
+
path.join(claimedPath(), `${id}.json`), path.join(failedPath(), `${id}.json`)
|
|
901
|
+
);
|
|
902
|
+
// Report the error.
|
|
903
|
+
await serveError({message: `ERROR: Report ${id} is not usable`}, response, false);
|
|
904
|
+
}
|
|
827
905
|
}
|
|
828
906
|
// Otherwise, i.e. if the job was not assigned to this worker:
|
|
829
907
|
else {
|
|
@@ -857,30 +935,27 @@ const requestHandler = async (request, response) => {
|
|
|
857
935
|
const segments = pathTail.split('/');
|
|
858
936
|
// If the service is to receive a test request:
|
|
859
937
|
if (segments[0] === 'requestTest') {
|
|
860
|
-
const {description, URL, reason} = postData;
|
|
938
|
+
const {description, URL, reason} = postData as {description: string; URL: string; reason: string};
|
|
861
939
|
// Get the response body.
|
|
862
|
-
const responseBody = await
|
|
863
|
-
.response([description, URL, reason]);
|
|
940
|
+
const responseBody = await apiRespond.requestTest([description, URL, reason]);
|
|
864
941
|
// Send it.
|
|
865
942
|
setHeaders('application/json', null, 'ultra');
|
|
866
943
|
response.end(JSON.stringify(responseBody));
|
|
867
944
|
}
|
|
868
945
|
// Otherwise, if the service is to receive a retest request:
|
|
869
946
|
else if (segments[0] === 'requestRetest') {
|
|
870
|
-
const {reason} = postData;
|
|
947
|
+
const {reason} = postData as {reason: string};
|
|
871
948
|
// Get the response body.
|
|
872
|
-
const responseBody = await
|
|
873
|
-
.response(segments.slice(1).concat(reason));
|
|
949
|
+
const responseBody = await apiRespond.requestRetest(segments.slice(1).concat(reason));
|
|
874
950
|
// Send it.
|
|
875
951
|
setHeaders('application/json', null, 'ultra');
|
|
876
952
|
response.end(JSON.stringify(responseBody));
|
|
877
953
|
}
|
|
878
954
|
// Otherwise, if the service is to receive a feature request:
|
|
879
955
|
else if (segments[0] === 'requestFeature') {
|
|
880
|
-
const {feature} = postData;
|
|
956
|
+
const {feature} = postData as {feature: string};
|
|
881
957
|
// Get the response body.
|
|
882
|
-
const responseBody = await
|
|
883
|
-
.response([feature]);
|
|
958
|
+
const responseBody = await apiRespond.requestFeature([feature]);
|
|
884
959
|
// Send it.
|
|
885
960
|
setHeaders('application/json', null, 'ultra');
|
|
886
961
|
response.end(JSON.stringify(responseBody));
|
|
@@ -893,10 +968,23 @@ const requestHandler = async (request, response) => {
|
|
|
893
968
|
}
|
|
894
969
|
}
|
|
895
970
|
// Otherwise, if it is a tutorial comment:
|
|
896
|
-
else if (pageName === '
|
|
897
|
-
const {content} = postData;
|
|
971
|
+
else if (pageName === 'tutorialWebComment.html') {
|
|
972
|
+
const {content} = postData as {content?: unknown};
|
|
973
|
+
setHeaders('application/json', null, 'low');
|
|
974
|
+
const answerData = await handleTutorialWebComment(content);
|
|
975
|
+
if (answerData.status === 'ok') {
|
|
976
|
+
response.end(JSON.stringify({status: 'ok'}));
|
|
977
|
+
}
|
|
978
|
+
else {
|
|
979
|
+
response.statusCode = 400;
|
|
980
|
+
response.end(JSON.stringify({status: 'error', message: answerData.message}));
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
// Otherwise, if it is an AI tutorial comment:
|
|
984
|
+
else if (pageName === 'tutorialAIComment.html') {
|
|
985
|
+
const {content} = postData as {content?: unknown};
|
|
898
986
|
setHeaders('application/json', null, 'low');
|
|
899
|
-
const answerData = await
|
|
987
|
+
const answerData = await handleTutorialAIComment(content);
|
|
900
988
|
if (answerData.status === 'ok') {
|
|
901
989
|
response.end(JSON.stringify({status: 'ok'}));
|
|
902
990
|
}
|
|
@@ -913,14 +1001,26 @@ const requestHandler = async (request, response) => {
|
|
|
913
1001
|
await serveError({message: 'ERROR: Invalid request method'}, response, true);
|
|
914
1002
|
}
|
|
915
1003
|
};
|
|
1004
|
+
// Handles a request, converting any error not already handled by handleRequest (i.e. one
|
|
1005
|
+
// that should never occur, such as a job or report file becoming unreadable or corrupt)
|
|
1006
|
+
// into a safe response instead of letting it crash the process as an unhandled rejection.
|
|
1007
|
+
const requestHandler = async (request: IncomingMessage, response: ServerResponse) => {
|
|
1008
|
+
try {
|
|
1009
|
+
await handleRequest(request, response);
|
|
1010
|
+
}
|
|
1011
|
+
catch (error: unknown) {
|
|
1012
|
+
console.error(`ERROR: Unhandled request error (${errorMessage(error)})`);
|
|
1013
|
+
await serveError({message: 'ERROR: Internal server error'}, response, false, 500);
|
|
1014
|
+
}
|
|
1015
|
+
};
|
|
916
1016
|
|
|
917
1017
|
// EXPORTS
|
|
918
1018
|
|
|
919
|
-
|
|
1019
|
+
export {requestHandler};
|
|
920
1020
|
|
|
921
1021
|
// SERVER
|
|
922
1022
|
|
|
923
|
-
const serve = async (protocolModule, options) => {
|
|
1023
|
+
const serve = async (protocolModule: typeof http | typeof https, options: {key?: string; cert?: string}) => {
|
|
924
1024
|
// Create any missing directories.
|
|
925
1025
|
for (const path of [queuePath(), claimedPath(), failedPath(), hiddenReportsPath(), reportsPath()]) {
|
|
926
1026
|
await fs.mkdir(path, {recursive: true});
|
|
@@ -929,16 +1029,17 @@ const serve = async (protocolModule, options) => {
|
|
|
929
1029
|
? https.createServer(options, requestHandler)
|
|
930
1030
|
: http.createServer(requestHandler);
|
|
931
1031
|
const port = process.env.PORT || '3000';
|
|
1032
|
+
const protocol = process.env.PROTOCOL || 'http';
|
|
932
1033
|
server.listen(port, () => {
|
|
933
1034
|
console.log(`Kilotest server listening at ${protocol}://localhost:${port}.`);
|
|
934
1035
|
});
|
|
935
1036
|
return server;
|
|
936
1037
|
};
|
|
937
1038
|
|
|
938
|
-
|
|
1039
|
+
export {serve};
|
|
939
1040
|
|
|
940
1041
|
// Starts the server using the configured protocol and credentials.
|
|
941
|
-
|
|
1042
|
+
export const startServer = async () => {
|
|
942
1043
|
const startProtocol = process.env.PROTOCOL || 'http';
|
|
943
1044
|
if (startProtocol === 'http') {
|
|
944
1045
|
console.log('Starting HTTP server');
|
|
@@ -946,19 +1047,19 @@ exports.startServer = async () => {
|
|
|
946
1047
|
}
|
|
947
1048
|
else if (startProtocol === 'https') {
|
|
948
1049
|
console.log('Starting HTTPS server');
|
|
949
|
-
const key = await fs.readFile(process.env.KEY, 'utf8');
|
|
950
|
-
const cert = await fs.readFile(process.env.CERT, 'utf8');
|
|
1050
|
+
const key = await fs.readFile(process.env.KEY as string, 'utf8');
|
|
1051
|
+
const cert = await fs.readFile(process.env.CERT as string, 'utf8');
|
|
951
1052
|
return serve(https, {key, cert});
|
|
952
1053
|
}
|
|
953
1054
|
};
|
|
954
1055
|
|
|
955
|
-
// Runs the server if the module was loaded directly (not required by a test).
|
|
956
|
-
|
|
1056
|
+
// Runs the server if the module was loaded directly (not required by a test). The starter is a parameter so tests can inject a spy, since ESM module exports cannot be monkey-patched.
|
|
1057
|
+
export const runIfMain = (mainModule: unknown, currentModule: unknown, starter: () => Promise<unknown> = startServer) => {
|
|
957
1058
|
if (mainModule === currentModule) {
|
|
958
|
-
|
|
1059
|
+
starter().catch(error => console.log(errorMessage(error)));
|
|
959
1060
|
}
|
|
960
1061
|
};
|
|
961
1062
|
|
|
962
1063
|
// EXECUTION
|
|
963
1064
|
|
|
964
|
-
|
|
1065
|
+
runIfMain(import.meta.main, true);
|