@jrpool/kilotest 38.1.0 → 39.0.1

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 (57) hide show
  1. package/.markdownlint-cli2.jsonc +12 -0
  2. package/.vscode/settings.json +3 -0
  3. package/CLAUDE.md +5 -0
  4. package/README.md +6 -4
  5. package/ai0BalanceForm/index.html +1 -1
  6. package/alerts.js +3 -1
  7. package/api/getReport.js +66 -0
  8. package/api/listDiagnoses.js +157 -0
  9. package/api/listIssues.js +225 -0
  10. package/api/listReports.js +87 -0
  11. package/api/listViolators.js +164 -0
  12. package/api/requestFeature.js +56 -0
  13. package/api/requestRetest.js +102 -0
  14. package/api/requestTest.js +106 -0
  15. package/api/routes.js +97 -0
  16. package/api/schemas.js +297 -0
  17. package/api/testSuite.js +294 -0
  18. package/api/util.js +149 -0
  19. package/diagnoses/index.js +2 -2
  20. package/docs/{AI-TOOL.md → AI-TOOLS.md} +54 -26
  21. package/docs/SERVICE.md +4 -1
  22. package/docs/decisions/001-api-design.md +59 -49
  23. package/eslint.config.mjs +13 -3
  24. package/index.html +1 -1
  25. package/index.js +202 -188
  26. package/issues/index.js +13 -21
  27. package/jsconfig.json +8 -0
  28. package/llms-full.txt +2 -2
  29. package/llms.txt +16 -11
  30. package/mcp.js +129 -65
  31. package/openapi.yaml +1234 -572
  32. package/package.json +18 -11
  33. package/reannotate/index.js +26 -15
  34. package/reannotateForm/index.js +15 -13
  35. package/redocly.yaml +4 -0
  36. package/reportHideForm/index.html +1 -1
  37. package/reportHideForm/index.js +24 -38
  38. package/reportIssue/index.js +3 -3
  39. package/reportIssues/index.js +128 -1
  40. package/reportUnhideForm/index.js +36 -44
  41. package/reportsExpungeForm/index.js +22 -16
  42. package/reportsPruneForm/index.js +22 -12
  43. package/reportsRewindForm/index.js +17 -9
  44. package/retestRec/index.js +8 -4
  45. package/retestRecForm/index.js +18 -6
  46. package/scripts/generate-openapi.js +83 -0
  47. package/targets/index.js +18 -13
  48. package/testOrder/index.js +11 -8
  49. package/tutorial/index.html +12 -12
  50. package/util.js +581 -539
  51. package/api/reportList.js +0 -83
  52. package/api/reportSummary.js +0 -181
  53. package/reportIssue/api.js +0 -183
  54. package/reportIssues/util.js +0 -138
  55. package/researchAgent.js +0 -206
  56. package/target/api.js +0 -126
  57. package/testRecForm/api.js +0 -55
@@ -5,7 +5,7 @@
5
5
 
6
6
  // IMPORTS
7
7
 
8
- const {getReportData, logsPath, reportsPath} = require('../util');
8
+ const {getReportData, makeReportsExtract, reportsPath} = require('../util');
9
9
  const fs = require('fs/promises');
10
10
  const path = require('path');
11
11
 
@@ -16,21 +16,31 @@ exports.answer = async (_, search) => {
16
16
  const searchParams = new URLSearchParams(search);
17
17
  const authCode = searchParams?.get('authCode');
18
18
  const jobNames = searchParams?.getAll('report');
19
- // If the form has been displayed by itself after a submission and any reports are to be deleted:
19
+ // If the form has displayed itself after a submission and any reports are to be deleted:
20
20
  if (jobNames?.length) {
21
21
  // If the authorization code is valid:
22
22
  if (authCode === process.env.AUTH_CODE) {
23
- // For each report to be deleted:
24
- for (const jobName of jobNames) {
25
- // Delete it.
26
- await fs.unlink(path.join(reportsPath, `${jobName}.json`));
27
- // Delete its log.
28
- await fs.unlink(path.join(logsPath, `${jobName}.json`));
23
+ try {
24
+ // For each report to be deleted:
25
+ for (const jobName of jobNames) {
26
+ // Delete it.
27
+ await fs.unlink(path.join(reportsPath, `${jobName}.json`));
28
+ }
29
+ // Update the extract of the available reports.
30
+ await makeReportsExtract();
31
+ }
32
+ // If this failed:
33
+ catch (error) {
34
+ // Return why.
35
+ return {
36
+ status: 'error',
37
+ message: `Deleting superseded reports failed (${error.message})`
38
+ }
29
39
  }
30
40
  }
31
41
  // Otherwise, i.e. if the authorization code is invalid:
32
42
  else {
33
- // Report the error.
43
+ // Return this.
34
44
  return {
35
45
  status: 'error',
36
46
  message: 'Invalid authorization code'
@@ -44,14 +54,14 @@ exports.answer = async (_, search) => {
44
54
  for (const reportName of reportNames) {
45
55
  const [timeStamp, jobID] = reportName.slice(0, -5).split('-');
46
56
  // Get a summary of it.
47
- const reportSummary = await getReportData(timeStamp, jobID);
48
- const {error, issueCount, preventedToolCount, url} = reportSummary;
57
+ const reportFacts = await getReportData(timeStamp, jobID);
58
+ const {error, issueCount, preventedToolCount, url} = reportFacts;
49
59
  // If this failed:
50
60
  if (error) {
51
61
  // Return why.
52
62
  return {
53
63
  status: 'error',
54
- message: error
64
+ message: error.message
55
65
  }
56
66
  }
57
67
  // Otherwise, i.e. if it succeeded, add the summary to the array.
@@ -5,7 +5,7 @@
5
5
 
6
6
  // IMPORTS
7
7
 
8
- const {getReportData, logsPath, reportsPath} = require('../util');
8
+ const {getReportData, reportsPath} = require('../util');
9
9
  const fs = require('fs/promises');
10
10
  const path = require('path');
11
11
 
@@ -20,12 +20,20 @@ exports.answer = async (_, search) => {
20
20
  if (jobNames?.length) {
21
21
  // If the authorization code is valid:
22
22
  if (authCode === process.env.AUTH_CODE) {
23
- // For each report to be deleted:
24
- for (const jobName of jobNames) {
25
- // Delete it.
26
- await fs.unlink(path.join(reportsPath, `${jobName}.json`));
27
- // Delete its log.
28
- await fs.unlink(path.join(logsPath, `${jobName}.json`));
23
+ try {
24
+ // For each report to be deleted:
25
+ for (const jobName of jobNames) {
26
+ // Delete it.
27
+ await fs.unlink(path.join(reportsPath, `${jobName}.json`));
28
+ }
29
+ }
30
+ // If this failed:
31
+ catch (error) {
32
+ // Return why.
33
+ return {
34
+ status: 'error',
35
+ message: `Deleting latest superseding reports failed (${error.message})`
36
+ }
29
37
  }
30
38
  }
31
39
  // Otherwise, i.e. if the authorization code is invalid:
@@ -43,8 +51,8 @@ exports.answer = async (_, search) => {
43
51
  for (const reportName of reportNames) {
44
52
  const [timeStamp, jobID] = reportName.slice(0, -5).split('-');
45
53
  // Get a summary of it.
46
- const reportSummary = await getReportData(timeStamp, jobID);
47
- const {error, issueCount, preventedToolCount, url} = reportSummary;
54
+ const reportFacts = await getReportData(timeStamp, jobID);
55
+ const {error, issueCount, preventedToolCount, url} = reportFacts;
48
56
  // If this failed:
49
57
  if (error) {
50
58
  // Return why.
@@ -5,16 +5,20 @@
5
5
 
6
6
  // IMPORTS
7
7
 
8
- const {getLog, processRec} = require('../util');
8
+ const {getLatestReportExtracts, processRec} = require('../util');
9
9
 
10
10
  // FUNCTIONS
11
11
 
12
12
  // Records a retest recommendation and returns an acknowledgement page.
13
13
  exports.answer = async (pageArgs, why) => {
14
14
  const [timeStamp, jobID] = pageArgs.split('/');
15
- // Get the log of the specified report.
16
- const log = await getLog(timeStamp, jobID);
17
- const {error, url, what} = log;
15
+ // Get data on the latest available reports.
16
+ const reportExtracts = await getLatestReportExtracts();
17
+ // Get data on the report whose page is to be retested.
18
+ const reportExtract = reportExtracts.find(
19
+ reportExtract => reportExtract.timeStamp === timeStamp && reportExtract.jobID === jobID
20
+ );
21
+ const {error, url, what} = reportExtract;
18
22
  // If this failed:
19
23
  if (error) {
20
24
  // Return why.
@@ -5,7 +5,7 @@
5
5
 
6
6
  // IMPORTS
7
7
 
8
- const {getAgoString, getDateTimeString, getLog} = require('../util');
8
+ const {getAgoString, getDateTimeString, getLatestReportExtracts} = require('../util');
9
9
  const fs = require('fs/promises');
10
10
  const path = require('path');
11
11
 
@@ -14,14 +14,26 @@ const path = require('path');
14
14
  // Returns a retest recommendation form.
15
15
  exports.answer = async pageArgs => {
16
16
  const [timeStamp, jobID] = pageArgs.split('/');
17
- const log = await getLog(timeStamp, jobID);
18
- // If this failed:
19
- if (log.error) {
17
+ // Get data on the latest available reports.
18
+ const reportExtracts = await getLatestReportExtracts();
19
+ // Get data on the report whose page is to be retested.
20
+ const reportExtract = reportExtracts.find(
21
+ reportExtract => reportExtract.timeStamp === timeStamp && reportExtract.jobID === jobID
22
+ );
23
+ // Initialize the page description.
24
+ let target;
25
+ // If getting the data succeeded:
26
+ if (reportExtract) {
27
+ // Update the page description.
28
+ target = reportExtract.what;
29
+ }
30
+ // Otherwise, i.e. if it failed:
31
+ else {
20
32
  // Make the form report the failure.
21
- log.what = 'The specified page is not available for retesting';
33
+ target = 'The specified page is not available for retesting';
22
34
  }
23
35
  const query = {
24
- target: log.what,
36
+ target,
25
37
  timeStamp,
26
38
  jobID,
27
39
  ago: getAgoString(timeStamp),
@@ -0,0 +1,83 @@
1
+ /*
2
+ generate-openapi.js
3
+ Generates openapi.yaml from the Kilotest API route table (api/routes.js) and Zod schemas (api/schemas.js), keeping the OpenAPI document in sync with schemas.js’s single source of truth.
4
+ */
5
+
6
+ // IMPORTS
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const yaml = require('js-yaml');
11
+ const {createDocument} = require('zod-openapi');
12
+ const {license, version} = require('../package.json');
13
+ const routes = require('../api/routes');
14
+
15
+ // CONSTANTS
16
+
17
+ const outputPath = path.join(__dirname, '..', 'openapi.yaml');
18
+
19
+ // FUNCTIONS
20
+
21
+ // Builds the paths object for createDocument from the route table.
22
+ const buildPaths = () => {
23
+ const paths = {};
24
+ routes.forEach(route => {
25
+ const {method, path: routePath, summary, pathParamsSchema, bodySchema, responseSchema} = route;
26
+ if (!paths[routePath]) {
27
+ paths[routePath] = {};
28
+ }
29
+ const operation = {
30
+ operationId: route.operationId,
31
+ summary,
32
+ responses: {
33
+ '200': {
34
+ description: summary,
35
+ content: {
36
+ 'application/json': {schema: responseSchema}
37
+ }
38
+ }
39
+ }
40
+ };
41
+ if (pathParamsSchema) {
42
+ operation.requestParams = {path: pathParamsSchema};
43
+ }
44
+ if (bodySchema) {
45
+ operation.requestBody = {
46
+ content: {'application/json': {schema: bodySchema}}
47
+ };
48
+ }
49
+ paths[routePath][method] = operation;
50
+ });
51
+ return paths;
52
+ };
53
+ // Generates and writes the openapi.yaml file.
54
+ const generate = () => {
55
+ const document = createDocument({
56
+ openapi: '3.1.0',
57
+ info: {
58
+ title: 'Kilotest API',
59
+ description: 'Kilotest is an ensemble testing and reporting service for front-end web quality. It uses an ensemble of 10 rule engines to test public web pages for front-end quality (accessibility, usability, and standards conformity). Kilotest acts as a web server for human users, an MCP server for AI platforms, and an API for programmatic access. With this API you can request that a page be tested or retested, discover available reports, incrementally retrieve facts from a report: from what reports are available, to what issues were reported in one report, to what elements violated one issue, to what diagnoses rule engines gave for one violation.',
60
+ version,
61
+ license: {
62
+ name: license,
63
+ identifier: license
64
+ },
65
+ contact: {
66
+ name: 'Kilotest',
67
+ url: 'https://github.com/jrpool/kilotest',
68
+ email: 'info@kilotest.com'
69
+ }
70
+ },
71
+ servers: [
72
+ {url: 'https://kilotest.com', description: 'Kilotest production server'}
73
+ ],
74
+ security: [],
75
+ paths: buildPaths()
76
+ });
77
+ fs.writeFileSync(outputPath, yaml.dump(document, {noRefs: true, lineWidth: -1}));
78
+ console.log(`Wrote ${outputPath}`);
79
+ };
80
+
81
+ // EXECUTION
82
+
83
+ generate();
package/targets/index.js CHANGED
@@ -9,13 +9,15 @@ const {
9
9
  getAgoDays,
10
10
  getCountString,
11
11
  getJobNames,
12
+ getMultiReportWhats,
12
13
  getObject,
13
14
  getPageDataStrings,
14
15
  getRecs,
15
- getLogs,
16
16
  getReportData,
17
+ getReportExtracts,
17
18
  isRecommendable,
18
- jobsPath
19
+ jobsPath,
20
+ objectSort
19
21
  } = require('../util');
20
22
  const fs = require('fs/promises');
21
23
  const path = require('path');
@@ -69,18 +71,21 @@ const populateQuery = async query => {
69
71
  query.noQueued = lines.queue.length ? '' : 'No pages are queued for testing.';
70
72
  // Add a no-claimed message, if applicable, to the query.
71
73
  query.noClaimed = lines.claimed.length ? '' : 'No pages are being tested now.';
72
- // Get the logs of the non-hidden reports.
73
- const targetLogs = await getLogs();
74
- query.which = targetLogs.length ? 'the following' : 'no';
75
- query.some = (targetLogs.length || jobFileNames.queue.length || jobFileNames.claimed.length)
74
+ // Get extracts of all available reports.
75
+ const reportExtracts = await getReportExtracts();
76
+ const reportCount = reportExtracts.length;
77
+ query.which = reportCount ? 'the following' : 'no';
78
+ query.some = (reportCount || jobFileNames.queue.length || jobFileNames.claimed.length)
76
79
  ? 'another'
77
80
  : 'a';
78
- const multiReportTargets = new Set(targetLogs.filter(log => log.superseded).map(log => log.what));
79
- // For each log:
80
- for (const targetLog of targetLogs) {
81
- const {jobName, url, what} = targetLog;
82
- const [timeStamp, jobID] = jobName.split('-');
83
- // Get data about its report.
81
+ const multiReportWhats = await getMultiReportWhats();
82
+ // Sort them primarily by page description and secondarily by completion time.
83
+ let sortedExtracts = objectSort(reportExtracts, 'reportTime', 'alpha');
84
+ sortedExtracts = objectSort(sortedExtracts, 'what', 'alpha');
85
+ // For each report:
86
+ for (const extract of sortedExtracts) {
87
+ const {jobID, timeStamp, url, what} = extract;
88
+ // Get data about it.
84
89
  const reportData = await getReportData(timeStamp, jobID);
85
90
  const {
86
91
  error,
@@ -104,7 +109,7 @@ const populateQuery = async query => {
104
109
  const daysAgo = getAgoDays(timeStamp);
105
110
  const pageDataStrings = await getPageDataStrings(timeStamp, jobID, {what, url, daysAgo});
106
111
  const {urlLink, testInfo} = pageDataStrings;
107
- const testText = multiReportTargets.has(what) ? ` (${testInfo.toLowerCase()})` : '';
112
+ const testText = multiReportWhats.includes(what) ? ` (${testInfo.toLowerCase()})` : '';
108
113
  lines.tested.push(`${margin} <summary>${what}${testText}</summary>`);
109
114
  lines.tested.push(`${margin} <ul>`);
110
115
  // Add the URL of the target to the lines.
@@ -5,7 +5,7 @@
5
5
 
6
6
  // IMPORTS
7
7
 
8
- const {getJSON, getNowStamp, getRandomString, getRecs, isURL} = require('../util');
8
+ const {getJSON, getNowStamp, getRandomString, getRecs, isURL, jobsPath, recsLock} = require('../util');
9
9
  const fs = require('fs/promises');
10
10
  const path = require('path');
11
11
 
@@ -33,15 +33,18 @@ exports.answer = async (url, what, authCode) => {
33
33
  };
34
34
  // Save the job in the queue.
35
35
  await fs.writeFile(
36
- path.join(__dirname, '..', 'jobs', 'queue', `${jobName}.json`), getJSON(job)
36
+ path.join(jobsPath, 'queue', `${jobName}.json`), getJSON(job)
37
37
  );
38
38
  console.log(`Retest queued for ${what} as job ${jobName}`);
39
- // Get the recommendations.
40
- const recs = await getRecs();
41
- // Delete the recommendations to retest the target.
42
- delete recs[url];
43
- // Save the revised recommendations.
44
- await fs.writeFile(path.join(__dirname, '..', 'jobs', 'recs.json'), getJSON(recs));
39
+ // Isolate this revision.
40
+ await recsLock(async () => {
41
+ // Get the recommendations.
42
+ const recs = await getRecs();
43
+ // Delete the recommendations to retest the target.
44
+ delete recs[url];
45
+ // Save the revised recommendations.
46
+ await fs.writeFile(path.join(jobsPath, 'recs.json'), getJSON(recs));
47
+ });
45
48
  // Get the answer template.
46
49
  let answerPage = await fs.readFile(path.join(__dirname, 'index.html'), 'utf8');
47
50
  // Replace its placeholders.
@@ -141,23 +141,23 @@
141
141
  }
142
142
  @media print {
143
143
  /* ── Layout ── */
144
- body { font-size: 11pt; }
145
- main { max-width: none; padding: 0; }
144
+ body {font-size: 11pt;}
145
+ main {max-width: none; padding: 0;}
146
146
 
147
147
  /* ── Hide elements that serve no purpose on a printed page ── */
148
148
  .skip-link,
149
149
  .tutorial-nav,
150
150
  #comment-form,
151
151
  #comment-diagnosis,
152
- #lesson1-quiz button { display: none; }
152
+ #lesson1-quiz button {display: none;}
153
153
 
154
154
  /* ── Page breaks: start each h2 section, h3 section, and h4 subsection on a new page ── */
155
- section.tutorial-section { break-before: page; }
156
- section.tutorial-section > section { break-before: page; }
157
- h3, h4 { break-before: page; }
155
+ section.tutorial-section {break-before: page;}
156
+ section.tutorial-section > section {break-before: page;}
157
+ h3, h4 {break-before: page;}
158
158
 
159
159
  /* ── Keep headings attached to the content that follows them ── */
160
- h2, h3, h4 { break-after: avoid; }
160
+ h2, h3, h4 {break-after: avoid;}
161
161
 
162
162
  /* ── Avoid splitting self-contained blocks across pages ── */
163
163
  pre,
@@ -165,15 +165,15 @@
165
165
  .ai-output,
166
166
  .note,
167
167
  dl.compare,
168
- .knowledge-check { break-inside: avoid; }
168
+ .knowledge-check {break-inside: avoid;}
169
169
 
170
170
  /* ── Force all details elements open so their content prints ── */
171
- details { display: block; }
172
- details > * { display: block; }
173
- details > summary::marker { content: ''; }
171
+ details {display: block;}
172
+ details > * {display: block;}
173
+ details > summary::marker {content: '';}
174
174
 
175
175
  /* ── Print the href after external links as a reference ── */
176
- a[href^="http"]::after { content: ' (' attr(href) ')'; font-size: 0.8em; }
176
+ a[href^="http"]::after {content: ' (' attr(href) ')'; font-size: 0.8em;}
177
177
  }
178
178
  </style>
179
179
  </head>