@jrpool/kilotest 46.0.14 → 46.1.2

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.
@@ -9,7 +9,30 @@
9
9
  "Exec(git show)",
10
10
  "Exec(git commit)",
11
11
  "Exec(git check-ignore)",
12
- "Exec(git remote)"
12
+ "Exec(git remote)",
13
+ "Exec(node)",
14
+ "Exec(source)",
15
+ "Exec(nvm use)",
16
+ "Exec(npm run)",
17
+ "Exec(lsof)",
18
+ "Exec(uname)",
19
+ "Exec(mkdir)",
20
+ "Exec(set)",
21
+ "Exec(git add)",
22
+ "Exec(gh)",
23
+ "Exec(/opt/homebrew/bin/colima start)",
24
+ "Exec(/opt/homebrew/bin/docker info)",
25
+ "Exec(/opt/homebrew/bin/docker run)",
26
+ "Exec($DOCKER volume)",
27
+ "Exec($DOCKER run)",
28
+ "Exec($DOCKER logs)",
29
+ "Exec($DOCKER exec)",
30
+ "Exec($DOCKER stop)",
31
+ "Exec(sed)",
32
+ "Exec(awk)",
33
+ "Exec(nvm ls)",
34
+ "Exec(.)",
35
+ "Exec(nvm alias)"
13
36
  ]
14
37
  }
15
38
  }
@@ -17,7 +17,7 @@ jobs:
17
17
  - name: Set Node.js version
18
18
  uses: actions/setup-node@v4
19
19
  with:
20
- node-version: '>=22.12.0'
20
+ node-version: '>=24.16.0'
21
21
 
22
22
  - name: Install dependencies
23
23
  run: npm ci
@@ -21,7 +21,7 @@ jobs:
21
21
  - name: Set Node.js version
22
22
  uses: actions/setup-node@v4
23
23
  with:
24
- node-version: '>=22.12.0'
24
+ node-version: '>=24.16.0'
25
25
 
26
26
  - name: Verify server.json version matches api/version.js
27
27
  run: |
package/README.md CHANGED
@@ -4,6 +4,7 @@
4
4
  [![license](https://img.shields.io/github/license/jrpool/kilotest?label=license)](LICENSE)
5
5
  [![MCP Registry](https://img.shields.io/badge/MCP_Registry-active-success)](https://registry.modelcontextprotocol.io/v0/servers?search=kilotest)
6
6
  [![GitHub stars](https://img.shields.io/github/stars/jrpool/kilotest?label=stars)](https://github.com/jrpool/kilotest/stargazers)
7
+ [![Indexed on TensorBlock MCP Index](https://mcp-index.tensorblock.co/v1/servers/github-jrpool-kilotest-f518ba36/badge.svg)](https://tensorblock.co/mcp/servers/github-jrpool-kilotest-f518ba36)
7
8
 
8
9
  An ensemble testing and reporting service for front-end web quality
9
10
 
@@ -63,6 +64,7 @@ There is additional documentation at:
63
64
 
64
65
  - [QAI](https://kilotest.com/qai): a tutorial for allowing AI assistants to use Kilotest
65
66
  - [AI-TOOLS.md](docs/AI-TOOLS.md): how Kilotest implements the MCP protocol
67
+ - [Summary of Kilotest capabilities](https://kilotest.com/capability.md)
66
68
  - [llms.txt](llms.txt): basic documentation for LLMs
67
69
  - [llms-full.txt](llms-full.txt): comprehensive documentation for LLMs
68
70
  - [lhm.plugin.json](lhm.plugin.json): documentation for registration with the LobeHub MCP Market
@@ -6,7 +6,7 @@
6
6
  // IMPORTS
7
7
 
8
8
  const {getReportBasics, getResponseMetadata, getToolsFacts, thisHost} = require('./util');
9
- const {getReportExtracts} = require('../util');
9
+ const {getLatestReportExtracts, getReportExtracts} = require('../util');
10
10
 
11
11
  // FUNCTIONS
12
12
 
@@ -22,11 +22,13 @@ exports.response = async () => {
22
22
  const reportsBasics = [];
23
23
  // Get extracts of all available reports.
24
24
  const reportExtracts = await getReportExtracts();
25
+ // Get the latest report extracts once, to avoid recomputing them for every report.
26
+ const latestReportExtracts = await getLatestReportExtracts();
25
27
  // For each report:
26
28
  for (const extract of reportExtracts) {
27
29
  const {error, jobID, timeStamp} = extract;
28
30
  // Get the basics about it (which may be only an error message).
29
- const reportBasics = await getReportBasics(timeStamp, jobID);
31
+ const reportBasics = await getReportBasics(timeStamp, jobID, extract, latestReportExtracts);
30
32
  // If this succeeded:
31
33
  if (!error) {
32
34
  // Add instructions for getting details to the basics.
@@ -5,7 +5,7 @@
5
5
 
6
6
  // IMPORTS
7
7
 
8
- const {test, before, after} = require('node:test');
8
+ const {test, before, beforeEach, after} = require('node:test');
9
9
  const assert = require('node:assert/strict');
10
10
  const alerts = require('../alerts');
11
11
 
@@ -14,13 +14,17 @@ const alerts = require('../alerts');
14
14
  let sendAlertCalls = [];
15
15
 
16
16
  before(() => {
17
- sendAlertCalls = [];
18
17
  // @ts-expect-error: Replacing the real function with a mock for testing.
19
18
  alerts.sendAlert = async (subject, body) => {
20
19
  sendAlertCalls.push({subject, body});
21
20
  };
22
21
  });
23
22
 
23
+ // Reset the call log before each test, so tests do not depend on execution order.
24
+ beforeEach(() => {
25
+ sendAlertCalls = [];
26
+ });
27
+
24
28
  // Require requestFeature after the mock is in place, so it captures the mocked sendAlert.
25
29
  const {response} = require('./requestFeature');
26
30
 
@@ -5,7 +5,7 @@
5
5
 
6
6
  // IMPORTS
7
7
 
8
- const {test, before, after} = require('node:test');
8
+ const {test, before, beforeEach, after} = require('node:test');
9
9
  const assert = require('node:assert/strict');
10
10
  const path = require('node:path');
11
11
  const apiUtil = require('./util');
@@ -17,13 +17,17 @@ let processTestRequestCalls = [];
17
17
 
18
18
  before(() => {
19
19
  process.env.DB_DIR = path.join(__dirname, '..', 'test', 'fixtures', 'db');
20
- processTestRequestCalls = [];
21
20
  // @ts-expect-error: Replacing the real function with a mock for testing.
22
21
  apiUtil.processTestRequest = async (testType, what, url, reason) => {
23
22
  processTestRequestCalls.push({testType, what, url, reason});
24
23
  };
25
24
  });
26
25
 
26
+ // Reset the call log before each test, so tests do not depend on execution order.
27
+ beforeEach(() => {
28
+ processTestRequestCalls = [];
29
+ });
30
+
27
31
  // Require requestRetest after the mock is in place, so it captures the mocked processTestRequest.
28
32
  const {response} = require('./requestRetest');
29
33
 
@@ -5,7 +5,7 @@
5
5
 
6
6
  // IMPORTS
7
7
 
8
- const {test, before, after} = require('node:test');
8
+ const {test, before, beforeEach, after} = require('node:test');
9
9
  const assert = require('node:assert/strict');
10
10
  const path = require('node:path');
11
11
  const apiUtil = require('./util');
@@ -17,13 +17,17 @@ let processTestRequestCalls = [];
17
17
 
18
18
  before(() => {
19
19
  process.env.DB_DIR = path.join(__dirname, '..', 'test', 'fixtures', 'db');
20
- processTestRequestCalls = [];
21
20
  // @ts-expect-error: Replacing the real function with a mock for testing.
22
21
  apiUtil.processTestRequest = async (testType, what, url, reason) => {
23
22
  processTestRequestCalls.push({testType, what, url, reason});
24
23
  };
25
24
  });
26
25
 
26
+ // Reset the call log before each test, so tests do not depend on execution order.
27
+ beforeEach(() => {
28
+ processTestRequestCalls = [];
29
+ });
30
+
27
31
  // Require requestTest after the mock is in place, so it captures the mocked processTestRequest.
28
32
  const {response} = require('./requestTest');
29
33
 
package/api/util.js CHANGED
@@ -35,7 +35,7 @@ exports.getResponseMetadata = () => ({
35
35
  exports.getToolsFacts = () => ({
36
36
  'name': 'Kilotest',
37
37
  'description': {
38
- 'what Kilotest does': 'Kilotest tools generate and make available findings about the front-end quality (i.e. accessibility, usability, and standards conformity) of web pages. A Kilotest job generates findings by using Testaro to test a page against more than a thousand rules defined by an ensemble of twelve rule engines. Testaro produces a report of the job. The report describes violations of the rules. Kilotest uses Testilo to enhance the report with a classification of the rule violations into about 300 issues. Kilotest makes facts about the issues and the violations retrievable at four levels of granularity.',
38
+ 'what Kilotest does': 'Kilotest tools generate and make available findings about the front-end quality (i.e. accessibility, usability, and standards conformity) of web pages. A Kilotest job generates findings by using Testaro to test a page against about 1300 rules defined by an ensemble of twelve rule engines. Testaro produces a report of the job. The report describes violations of the rules. Kilotest uses Testilo to enhance the report with a classification of the rule violations into about 380 issues. Kilotest makes facts about the issues and the violations retrievable at four levels of granularity.',
39
39
  'how to retrieve findings': {
40
40
  'level 1': 'Use the listReports tool to get a list of available reports.',
41
41
  'level 2': 'Use the listIssues tool to get a list of issues in one report.',
@@ -68,28 +68,32 @@ exports.getRuleEnginesFacts = ruleEngineIDSet => {
68
68
  return ruleEnginesFacts;
69
69
  };
70
70
  // Returns the basics about a report.
71
- exports.getReportBasics = async (timeStamp, jobID) => {
72
- // Get the creation time of the report.
73
- const reportStats = await getReportStats(timeStamp, jobID);
74
- // If the report does not exist:
75
- if (!reportStats) {
76
- // Log and return this.
77
- console.error(`Report ${timeStamp}-${jobID} does not exist.`);
78
- return {
79
- error: `Report ${timeStamp}-${jobID} could not be retrieved.`
80
- };
71
+ // Accepts optional precomputed extract and latestReportExtracts to avoid redundant
72
+ // reads when called in a loop over all reports (e.g. by listReports).
73
+ exports.getReportBasics = async (timeStamp, jobID, extract = null, latestReportExtracts = null) => {
74
+ // If an extract was not provided, verify the report exists and read it.
75
+ if (!extract) {
76
+ // Get the creation time of the report.
77
+ const reportStats = await getReportStats(timeStamp, jobID);
78
+ // If the report does not exist:
79
+ if (!reportStats) {
80
+ // Log and return this.
81
+ console.error(`Report ${timeStamp}-${jobID} does not exist.`);
82
+ return {
83
+ error: `Report ${timeStamp}-${jobID} could not be retrieved.`
84
+ };
85
+ }
86
+ // Otherwise, i.e. if it exists, get an extract of the report.
87
+ extract = await getReportExtract(timeStamp, jobID);
88
+ }
89
+ const {url, what, reportTime} = extract;
90
+ // If the latest report extracts were not provided, compute them.
91
+ if (!latestReportExtracts) {
92
+ latestReportExtracts = await getLatestReportExtracts();
81
93
  }
82
- // Otherwise, i.e. if it exists, get an extract of the report.
83
- const reportExtract = await getReportExtract(timeStamp, jobID);
84
- const {url, what} = reportExtract;
85
- const latestReportExtracts = await getLatestReportExtracts();
86
- // Otherwise, i.e. if the report exists, get whether this report has been superseded.
94
+ // Get whether this report has been superseded.
87
95
  const isSuperseded = latestReportExtracts
88
- .every(extract => extract.timeStamp !== timeStamp || extract.jobID !== jobID);
89
- // Get the completion time from the report content, not the file system birth time,
90
- // because the birth time depends on when the file was created on the file system
91
- // and does not reflect the actual report completion time.
92
- const {reportTime} = reportExtract;
96
+ .every(ex => ex.timeStamp !== timeStamp || ex.jobID !== jobID);
93
97
  // Get the basics about the report.
94
98
  const basics = {
95
99
  identifier: `${timeStamp}-${jobID}`,
@@ -77,7 +77,7 @@ const request = (method, requestPath, body = null) => new Promise((resolve, reje
77
77
 
78
78
  // TESTS
79
79
 
80
- test('GET /api/listReports returns valid JSON through the HTTP routing layer', async () => {
80
+ test('GET /api/listReports returns valid JSON through the HTTP routing layer', {timeout: 500}, async () => {
81
81
  const {body} = await request('GET', '/api/listReports');
82
82
  assert.equal(body['tool name'], 'listReports');
83
83
  const reports = body['response content']['basics about all available reports'];
package/capability.md ADDED
@@ -0,0 +1,51 @@
1
+ # Kilotest capability manifest
2
+
3
+ ## Capability
4
+
5
+ Web-page front-end quality audit. Kilotest tests public web pages for front-end quality, namely accessibility, usability, and standards conformity, and makes structured findings available for retrieval.
6
+
7
+ ## Purpose
8
+
9
+ Language models and AI platforms often need accurate, page-specific data about the front-end quality of a web page to answer user questions. Rule engines, such as `axe-core`, `QualWeb`, and `WAVE`, provide such data, but with large coverage gaps. Orchestrating and reconciling numerous complementary rule engines would be difficult. Kilotest solves that problem by testing pages with an ensemble of numerous independent rule engines, making their results comparable, saving the results as JSON reports, mining the reports for facts at selectable levels of detail.
10
+
11
+ ## Interfaces
12
+
13
+ - MCP server (Streamable HTTP): `https://kilotest.com/mcp`
14
+ - OpenAPI specification: `https://kilotest.com/openapi.yaml`
15
+ - HTTP API: see `https://kilotest.com/openapi.yaml` for endpoints
16
+ - Web UI: `https://kilotest.com/`
17
+ - LLM documentation: `https://kilotest.com/llms.txt` and `https://kilotest.com/llms-full.txt`
18
+
19
+ ## Initial input
20
+
21
+ A request for a list of available reports.
22
+
23
+ ## Initial output
24
+
25
+ A list of available reports.
26
+
27
+ ## Subsequent inputs
28
+
29
+ - If there is no report about the wanted page, a request to test it, with a page description and its URL.
30
+ - If there is a report about the wanted page, requests for findings from the report at successive levels of detail.
31
+
32
+ ## Subsequent outputs
33
+
34
+ - From a request to test a page: confirmation of receipt. Upon approval and execution, the list of available reports will include the new report.
35
+ - From a request for findings: Details about issues reported, related WCAG standards, what rules were violated, and which DOM elements violated them.
36
+
37
+ ## Side effects
38
+
39
+ Kilotest reads the specified public web page during testing. It does not modify the target site. Testing is asynchronous and may take up to approximately one day before reports are available for newly requested pages.
40
+
41
+ ## Cost
42
+
43
+ Free. No authentication is required for the web UI or the public MCP endpoint.
44
+
45
+ ## License
46
+
47
+ MIT.
48
+
49
+ ## Repository
50
+
51
+ `https://github.com/jrpool/kilotest`
package/checkinfo.js ADDED
@@ -0,0 +1,146 @@
1
+ /*
2
+ checkinfo.js
3
+ Summarizes the validation scope of the lint, test, and test:smoke scripts by deriving file and test-case counts from their actual configurations, so the summary stays accurate as the codebase changes.
4
+ */
5
+
6
+ // IMPORTS
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const {execSync} = require('child_process');
11
+
12
+ // CONSTANTS
13
+
14
+ const rootDir = __dirname;
15
+ const excludeDirs = new Set(['node_modules', '.git', 'coverage']);
16
+
17
+ // FUNCTIONS
18
+
19
+ // Recursively collects all files under a directory, skipping excluded directories.
20
+ const collectFiles = dir => {
21
+ const results = [];
22
+ for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
23
+ const fullPath = path.join(dir, entry.name);
24
+ if (entry.isDirectory()) {
25
+ if (!excludeDirs.has(entry.name)) {
26
+ results.push(...collectFiles(fullPath));
27
+ }
28
+ }
29
+ else {
30
+ results.push(fullPath);
31
+ }
32
+ }
33
+ return results;
34
+ };
35
+
36
+ // Converts a glob pattern to a RegExp for matching relative paths.
37
+ // Supports * (non-segment), ** (any), and literal characters.
38
+ const globToRegExp = pattern => {
39
+ const escaped = pattern
40
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
41
+ .replace(/\*\*/g, '\x00')
42
+ .replace(/\*/g, '[^/]*')
43
+ .replace(/\x00/g, '.*');
44
+ return new RegExp('^' + escaped + '$');
45
+ };
46
+
47
+ // Returns whether a relative path matches any pattern in a list.
48
+ const matchesAny = (relPath, patterns) => patterns.some(p => globToRegExp(p).test(relPath));
49
+
50
+ // Extracts the top-level ignores array from eslint.config.mjs.
51
+ const getEslintIgnores = () => {
52
+ const content = fs.readFileSync(path.join(rootDir, 'eslint.config.mjs'), 'utf8');
53
+ const match = content.match(/ignores:\s*\[([\s\S]*?)\]/);
54
+ if (!match) {
55
+ return [];
56
+ }
57
+ return [...match[1].matchAll(/"([^"]+)"/g)].map(m => m[1]);
58
+ };
59
+
60
+ // Parses .markdownlint-cli2.jsonc and returns its ignores array.
61
+ const getMarkdownlintIgnores = () => {
62
+ const content = fs.readFileSync(path.join(rootDir, '.markdownlint-cli2.jsonc'), 'utf8');
63
+ const stripped = content.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
64
+ const match = stripped.match(/"ignores"\s*:\s*\[([\s\S]*?)\]/);
65
+ if (!match) {
66
+ return [];
67
+ }
68
+ return [...match[1].matchAll(/"([^"]+)"/g)].map(m => m[1]);
69
+ };
70
+
71
+ // Runs the Node test runner with the TAP reporter and parses the test count, so dynamically generated tests (e.g., from for-loops) are counted accurately.
72
+ const getTestCaseCount = () => {
73
+ const output = execSync(
74
+ 'node --require ./test/setup.cjs --test --test-concurrency=1 --test-timeout=100 --test-reporter=tap',
75
+ {cwd: rootDir, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe']}
76
+ );
77
+ const match = output.match(/^# tests (\d+)$/m);
78
+ return match ? Number(match[1]) : 0;
79
+ };
80
+
81
+ // MAIN
82
+
83
+ const allFiles = collectFiles(rootDir);
84
+ const relFiles = allFiles.map(f => path.relative(rootDir, f));
85
+
86
+ const eslintIgnores = getEslintIgnores();
87
+ const markdownlintIgnores = getMarkdownlintIgnores();
88
+
89
+ // ESLint file counts by extension, excluding ignored files.
90
+ const eslintExts = {js: 0, json: 0, md: 0, css: 0};
91
+ for (const rel of relFiles) {
92
+ if (matchesAny(rel, eslintIgnores)) {
93
+ continue;
94
+ }
95
+ if (/\.(js|mjs|cjs)$/.test(rel)) {
96
+ eslintExts.js++;
97
+ }
98
+ else if (/\.json$/.test(rel)) {
99
+ eslintExts.json++;
100
+ }
101
+ else if (/\.md$/.test(rel)) {
102
+ eslintExts.md++;
103
+ }
104
+ else if (/\.css$/.test(rel)) {
105
+ eslintExts.css++;
106
+ }
107
+ }
108
+ const eslintTotal = Object.values(eslintExts).reduce((a, b) => a + b, 0);
109
+
110
+ // markdownlint file count (markdown files not ignored).
111
+ const markdownlintCount = relFiles.filter(
112
+ rel => rel.endsWith('.md') && !matchesAny(rel, markdownlintIgnores)
113
+ ).length;
114
+
115
+ // Test file and test-case counts.
116
+ const testFiles = relFiles.filter(rel => /\.test\.js$/.test(rel));
117
+ const testCaseCount = getTestCaseCount();
118
+
119
+ // Smoke-test path counts from the routes table in index.js.
120
+ const {routes} = require('./index');
121
+ const smokeGetCount = routes.GET.length;
122
+ const smokePostCount = routes.POST.length;
123
+ const smokeTotal = smokeGetCount + smokePostCount;
124
+
125
+ // OUTPUT
126
+
127
+ const parts = [
128
+ ['lint', [
129
+ `ESLint: ${eslintTotal} files (${eslintExts.js} JS, ${eslintExts.json} JSON, ${eslintExts.md} MD, ${eslintExts.css} CSS)`,
130
+ `markdownlint: ${markdownlintCount} files`
131
+ ]],
132
+ ['test', [
133
+ `${testFiles.length} test files, ${testCaseCount} test cases`
134
+ ]],
135
+ ['test:smoke', [
136
+ `${smokeTotal} paths (${smokeGetCount} GET, ${smokePostCount} POST)`
137
+ ]]
138
+ ];
139
+
140
+ console.log('Validation summary:');
141
+ for (const [name, lines] of parts) {
142
+ console.log(` ${name}:`);
143
+ for (const line of lines) {
144
+ console.log(` ${line}`);
145
+ }
146
+ }
package/docs/AI-TOOLS.md CHANGED
@@ -27,6 +27,7 @@ The internal features that make Kilotest a collection of tools for language mode
27
27
  - A generated `openapi.yaml` file, documenting the Kilotest API, conforming to the [OpenAPI specification](https://spec.openapis.org/oas/v3.1.0).
28
28
  - An `mcp.js` file, providing an MCP server for Kilotest.
29
29
  - A `server.json` file, conforming to the [MCP server schema](https://modelcontextprotocol.io/), and a GitHub Actions workflow (`publish-mcp.yml`) that authenticates via GitHub OIDC and publishes that file to the official MCP Registry whenever `server.json` or `api/version.js` changes on the `main` branch.
30
+ - A `capability.md` file with a URL, summarizing what Kilotest tools can do.
30
31
  - `mcp`, `mcp-server`, and `modelcontextprotocol` keywords in `package.json`, supporting discovery of the `@jrpool/kilotest` npm package by tools and humans searching the npm registry for MCP servers.
31
32
  - An `lhm.plugin.json` file, conforming to the [LobeHub plugin manifest format](https://market.lobehub.com/s/publish-mcp/references/manifest), declaring Kilotest as a plugin in the [LobeHub MCP Market](https://lobehub.com/mcp). Publishing and updating are non-automated (`lhm plugin publish`/`update`), because LobeHub requires browser-based OAuth and does not support machine-to-machine publishing.
32
33
  - A `sitemap.xml` file.
@@ -44,7 +45,7 @@ The external features that support the use of Kilotest as a collection of AI too
44
45
  - Registration of Kilotest as an active server in the [official MCP Registry](https://registry.modelcontextprotocol.io/v0/servers?search=kilotest) (`io.github.jrpool/kilotest`), maintained by the [Model Context Protocol](https://modelcontextprotocol.io/) project. The [PulseMCP](https://www.pulsemcp.com/servers) directory and the VS Code MCP gallery both crawl this registry, so Kilotest is discoverable through them with no separate submission.
45
46
  - Registration of Kilotest with the [Smithery](https://smithery.ai/servers/pool/kilotest) MCP server registry.
46
47
  - Registration of Kilotest with the [Glama](https://glama.ai/mcp/connectors/com.kilotest/kilotest) MCP server registry.
47
- - a [pull request](https://github.com/TensorBlock/awesome-mcp-servers/pull/2221) (on 2026-09-07) to add Kilotest to the [Awesome MCP Servers](https://github.com/TensorBlock/awesome-mcp-servers) list.
48
+ - Registration of Kilotest with [Awesome MCP Servers](https://www.tensorblock.co/mcp/servers/github-jrpool-kilotest-f518ba36).
48
49
  - Registration of Kilotest with the [RapidAPI](https://rapidapi.com/jrpool/api/kilotest/playground/apiendpoint_0f03577a-ff9a-472a-a0ed-533bd198981a) Hub.
49
50
  - Registration of Kilotest with the [LobeHub MCP Market](https://lobehub.com/mcp) (identifier: `jrpool-kilotest`), enabled with an `lhm.plugin.json` file.
50
51
  - Deployment of an MCP server in HTTP mode on the Kilotest service host.
@@ -99,3 +100,32 @@ Other future work:
99
100
 
100
101
  - Add to QAI, or replace much of the current QAI content with, an [Add Custom Connector link](https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=Kilotest&connectorUrl=https%3A%2F%2Fkilotest.com%2Fmcp).
101
102
  - Consider subscribing to a `Claude.ai` Team plan and submitting Kilotest to the [Claude Connectors Directory](https://claude.com/docs/connectors/building/submission). Details provided by GLM-5.2 High: “If accepted, Kilotest would get a permanent listing URL like `https://claude.ai/directory/connectors/kilotest` with a Connect button, and would appear in Claude’s in-product browse/search and ‘Suggested Connectors’. This is the highest-value listing for your use cases, because it puts Kilotest directly in front of Claude users who have not explicitly searched for it.”
103
+ - Recommended by GLM-5.2 High: Submit a Kilotest-appropriate request to one or more AI platforms and discover whether the model discovers Kilotest and, if so, either uses it or offers to use it in the creation of a response.
104
+ - Recommended by ChatGPT: A general capability manifest that says, in one machine-readable place: Here are the capabilities Kilotest provides; here are all the interfaces through which you can invoke them; here are their requirements and effects. For example:
105
+
106
+ ```markdown
107
+ Capability: web-page-quality-audit
108
+
109
+ Purpose:
110
+ Test a public web page for accessibility,
111
+ usability, HTML and web-standards problems.
112
+
113
+ Interfaces:
114
+ MCP: https://kilotest.com/mcp
115
+ OpenAPI: https://kilotest.com/openapi.yaml
116
+ Web UI: https://kilotest.com/
117
+
118
+ Input:
119
+ Public URL
120
+
121
+ Output:
122
+ Structured findings, affected elements,
123
+ rule-engine diagnoses, WCAG mappings
124
+
125
+ Side effects:
126
+ Reads the specified public web page.
127
+ Does not modify the target site.
128
+
129
+ Cost:
130
+ ...
131
+ ```
package/docs/SERVICE.md CHANGED
@@ -199,7 +199,7 @@ kilotest.com {
199
199
  # Specify the only paths of forwardable requests.
200
200
  @allowedGET {
201
201
  method GET
202
- path /mcp / /index.html /robots.txt /openapi.yaml /openapi.json /swagger.yaml /swagger.json /api-docs /llms.txt /llms-full.txt *.html* /fullReport.json/* /api/* /tutorial/images/* /favicon.* /style.css /sitemap.xml
202
+ path /mcp / /index.html /robots.txt /openapi.yaml /openapi.json /swagger.yaml /swagger.json /api-docs /llms.txt /llms-full.txt /capability.md *.html* /fullReport.json/* /api/* /tutorial/images/* /favicon.* /style.css /sitemap.xml
203
203
  }
204
204
  @allowedPOST {
205
205
  method POST
package/index.html CHANGED
@@ -71,6 +71,7 @@
71
71
  <p>The rule engines perform about 1,300 tests in total. <a href="https://medium.com/cvs-health-tech-blog/how-to-run-a-thousand-accessibility-tests-63692ad120c3">Open-source software</a> developed initially at CVS Health drives the rule engines, tries to weed out invalid results, and distills the findings into about 380 <q>issues</q>.</p>
72
72
  <h2>Features</h2>
73
73
  <p>With Kilotest, you can get results of already performed tests and propose web pages for testing. (Immediate testing on request may become available in a future version.)</p>
74
+ <p>There is a <a href="/capability.md">capability document</a> that summarizes what Kilotest can do for human and AI-agent users.</p>
74
75
  <h2>How to use Kilotest</h2>
75
76
  <h3>If you are a human user on the web</h3>
76
77
  <p>You can use Kilotest starting on this page.</p>
package/index.js CHANGED
@@ -66,36 +66,37 @@ const answer = {
66
66
  // Paths that the application is authorized to handle, by method, as glob-style patterns where * matches any sequence of characters.
67
67
  const routes = exports.routes = {
68
68
  GET: [
69
- '/mcp',
69
+ '*.html*',
70
70
  '/',
71
- '/index.html',
72
- '/robots.txt',
73
- '/openapi.yaml',
74
- '/openapi.json',
75
- '/swagger.yaml',
76
- '/swagger.json',
77
71
  '/api-docs',
78
- '/llms.txt',
72
+ '/api/*',
73
+ '/capability.md',
74
+ '/favicon.*',
75
+ '/fullReport.json/*',
76
+ '/index.html',
79
77
  '/llms-full.txt',
78
+ '/llms.txt',
79
+ '/mcp',
80
+ '/openapi.json',
81
+ '/openapi.yaml',
82
+ '/robots.txt',
80
83
  '/sitemap.xml',
81
84
  '/style.css',
82
- '/fullReport.json/*',
83
- '/api/*',
84
- '/tutorial/images/*',
85
- '/favicon.*',
86
- '*.html*'
85
+ '/swagger.json',
86
+ '/swagger.yaml',
87
+ '/tutorial/images/*'
87
88
  ],
88
89
  POST: [
90
+ '/api/*',
89
91
  '/mcp',
90
- '/requestTest.html',
91
- '/requestRetest.html/*',
92
- '/recAction.html',
93
92
  '/reannotate.html',
93
+ '/recAction.html',
94
94
  '/renewWCAG.html',
95
+ '/requestRetest.html/*',
96
+ '/requestTest.html',
97
+ '/tutorialComment.html',
95
98
  '/worker/job',
96
- '/worker/report',
97
- '/api/*',
98
- '/tutorialComment.html'
99
+ '/worker/report'
99
100
  ]
100
101
  };
101
102
  const protocol = process.env.PROTOCOL || 'http';
@@ -359,6 +360,12 @@ const requestHandler = async (request, response) => {
359
360
  const {pathname, search} = requestURL;
360
361
  const pageName = pathname.split('/')[1];
361
362
  const pathTail = pathname.split('/').slice(2).join('/');
363
+ // If the request is a smoke-test probe, respond perfunctorily without executing a handler.
364
+ if (request.headers['x-kilotest-smoke']) {
365
+ response.setHeader('content-type', 'application/json; charset=utf-8');
366
+ response.end('{}');
367
+ return;
368
+ }
362
369
  // If the request is a GET request:
363
370
  if (method === 'GET') {
364
371
  // If the path is not authorized for GET requests:
@@ -412,6 +419,13 @@ const requestHandler = async (request, response) => {
412
419
  setHeaders('text/plain', '/llms-full.txt', 'medium');
413
420
  response.end(llmsfull);
414
421
  }
422
+ // Otherwise, if it is for the summary of capabilities:
423
+ else if (pageName === 'capability.md') {
424
+ const capabilityDoc = await fs.readFile('capability.md', 'utf8');
425
+ // Serve it.
426
+ setHeaders('text/markdown', '/capability.md', 'medium');
427
+ response.end(capabilityDoc);
428
+ }
415
429
  // Otherwise, if it is for the XML sitemap:
416
430
  else if (pageName === 'sitemap.xml') {
417
431
  const sitemap = await fs.readFile('sitemap.xml', 'utf8');
package/index.test.js CHANGED
@@ -18,7 +18,7 @@ process.env.TESTARO_WORKERS = JSON.stringify({
18
18
 
19
19
  // IMPORTS
20
20
 
21
- const {test, before, after} = require('node:test');
21
+ const {test, before, beforeEach, after} = require('node:test');
22
22
  const assert = require('node:assert/strict');
23
23
  const http = require('node:http');
24
24
  const fs = require('node:fs/promises');
@@ -35,7 +35,12 @@ const recsPath = path.join(fixtureDBDir, 'jobs', 'recs.json');
35
35
  let server;
36
36
 
37
37
  before(async () => {
38
- // Restore recs.json and clean job directories before running.
38
+ server = http.createServer(requestHandler);
39
+ await new Promise(resolve => server.listen(port, () => resolve()));
40
+ });
41
+
42
+ // Restore recs.json and clean job directories before each test, so tests do not depend on execution order.
43
+ beforeEach(async () => {
39
44
  await fs.writeFile(recsPath, '{}\n');
40
45
  for (const sub of ['claimed', 'queue', 'failed']) {
41
46
  const dir = path.join(fixtureDBDir, 'jobs', sub);
@@ -45,8 +50,6 @@ before(async () => {
45
50
  await fs.unlink(path.join(dir, file)).catch(() => {});
46
51
  }
47
52
  }
48
- server = http.createServer(requestHandler);
49
- await new Promise(resolve => server.listen(port, () => resolve()));
50
53
  });
51
54
 
52
55
  after(async () => {
@@ -144,6 +147,26 @@ test('routes has GET and POST arrays', () => {
144
147
  assert.ok(Array.isArray(routes.POST));
145
148
  });
146
149
 
150
+ // TESTS: smoke-test short-circuit
151
+
152
+ test('GET with x-kilotest-smoke header returns a perfunctory 200', async () => {
153
+ const res = await request('GET', '/api/listReports', null, {'x-kilotest-smoke': '1'});
154
+ assert.equal(res.statusCode, 200);
155
+ assert.equal(res.body, '{}');
156
+ });
157
+
158
+ test('POST with x-kilotest-smoke header returns a perfunctory 200', async () => {
159
+ const res = await request('POST', '/api/requestFeature', {}, {'x-kilotest-smoke': '1'});
160
+ assert.equal(res.statusCode, 200);
161
+ assert.equal(res.body, '{}');
162
+ });
163
+
164
+ test('GET without x-kilotest-smoke header executes the handler', async () => {
165
+ const res = await request('GET', '/api/listReports');
166
+ assert.equal(res.statusCode, 200);
167
+ assert.notEqual(res.body, '{}');
168
+ });
169
+
147
170
  // TESTS: GET routes
148
171
 
149
172
  test('GET / serves the home page as HTML', async () => {
@@ -211,6 +234,13 @@ test('GET /llms-full.txt serves the LLM detailed guide as text', async () => {
211
234
  assert.ok(res.body.length > 0);
212
235
  });
213
236
 
237
+ test('GET /capability.md serves the capability manifest as markdown', async () => {
238
+ const res = await request('GET', '/capability.md');
239
+ assert.equal(res.statusCode, 200);
240
+ assert.ok(res.headers['content-type'].includes('text/markdown'));
241
+ assert.ok(res.body.length > 0);
242
+ });
243
+
214
244
  test('GET /sitemap.xml serves the sitemap as XML', async () => {
215
245
  const res = await request('GET', '/sitemap.xml');
216
246
  assert.equal(res.statusCode, 200);
@@ -251,7 +281,7 @@ test('GET /api/listViolators/linkNoText/260101T0000/mix returns JSON', async ()
251
281
  assert.equal(body['tool name'], 'listViolators');
252
282
  });
253
283
 
254
- test('GET /api/listDiagnoses/0/linkNoText/260101T0000/mix returns JSON', async () => {
284
+ test('GET /api/listDiagnoses/0/linkNoText/260101T0000/mix returns JSON', {timeout: 500}, async () => {
255
285
  const res = await request('GET', '/api/listDiagnoses/0/linkNoText/260101T0000/mix');
256
286
  assert.equal(res.statusCode, 200);
257
287
  const body = jsonBody(res);
@@ -265,7 +295,7 @@ test('GET /api/getReport/260101T0000/mix returns JSON with the report', async ()
265
295
  assert.equal(body['tool name'], 'getReport');
266
296
  });
267
297
 
268
- test('GET /api/invalidService returns an error', async () => {
298
+ test('GET /api/invalidService returns an error', {timeout: 500}, async () => {
269
299
  const res = await request('GET', '/api/invalidService');
270
300
  assert.equal(res.statusCode, 400);
271
301
  const body = jsonBody(res);
@@ -281,7 +311,7 @@ test('GET /fullReport.json/260101T0000/mix downloads the report as JSON', async
281
311
  assert.ok(body.target);
282
312
  });
283
313
 
284
- test('GET /fullReport.json/invalid/invalid returns an error page', async () => {
314
+ test('GET /fullReport.json/invalid/invalid returns an error page', {timeout: 500}, async () => {
285
315
  const res = await request('GET', '/fullReport.json/invalid/invalid');
286
316
  assert.equal(res.statusCode, 400);
287
317
  assert.ok(res.headers['content-type'].includes('text/html'));
@@ -382,7 +412,7 @@ test('POST /requestRetest.html/invalid/invalid with invalid data returns an erro
382
412
  assert.ok(res.body.includes('Invalid retest recommendation'));
383
413
  });
384
414
 
385
- test('POST /api/requestTest with valid JSON returns a JSON response', async () => {
415
+ test('POST /api/requestTest with valid JSON returns a JSON response', {timeout: 500}, async () => {
386
416
  const res = await request('POST', '/api/requestTest', {
387
417
  description: `API Test Page ${uniqueStamp}`,
388
418
  URL: `https://example.com/api-test-${uniqueStamp}`,
@@ -502,7 +532,7 @@ test('serveError sends HTML for human requests (isHumanUser = true)', async () =
502
532
 
503
533
  // TESTS: recAction.html
504
534
 
505
- test('POST /recAction.html with invalid auth code returns an error', async () => {
535
+ test('POST /recAction.html with invalid auth code returns an error', {timeout: 500}, async () => {
506
536
  const res = await formRequest('POST', '/recAction.html', {
507
537
  target: 'https://example.com\tTest Page',
508
538
  authCode: 'wrong-code'
@@ -542,7 +572,7 @@ test('GET /fullReport.json/260101T0007/hid returns an abuse error for a hidden r
542
572
  assert.ok(res.body.includes('Invalid request'));
543
573
  });
544
574
 
545
- test('POST /recAction.html with valid auth code and approval returns HTML', async () => {
575
+ test('POST /recAction.html with valid auth code and approval returns HTML', {timeout: 500}, async () => {
546
576
  await fs.writeFile(recsPath, '{}\n');
547
577
  await formRequest('POST', '/requestTest.html', {
548
578
  what: `Approval Test Page ${uniqueStamp}`,
@@ -619,7 +649,7 @@ test('POST /renewWCAG.html with valid auth code serves the answer page', async (
619
649
  }
620
650
  });
621
651
 
622
- test('POST /tutorialComment.html with empty content returns a JSON error', async () => {
652
+ test('POST /tutorialComment.html with empty content returns a JSON error', {timeout: 500}, async () => {
623
653
  const res = await request('POST', '/tutorialComment.html', {
624
654
  content: ''
625
655
  });
@@ -691,7 +721,7 @@ test('POST /worker/job with no colon in decoded credentials returns 401', async
691
721
 
692
722
  // TESTS: processJobRequest branches
693
723
 
694
- test('POST /worker/job with a claimed job assigned to the worker returns an error and reclassifies the job', async () => {
724
+ test('POST /worker/job with a claimed job assigned to the worker returns an error and reclassifies the job', {timeout: 500}, async () => {
695
725
  // Create a claimed job assigned to Worker One.
696
726
  const claimedDir = path.join(fixtureDBDir, 'jobs', 'claimed');
697
727
  const failedDir = path.join(fixtureDBDir, 'jobs', 'failed');
@@ -712,6 +742,8 @@ test('POST /worker/job with a claimed job assigned to the worker returns an erro
712
742
  // The worker should get an error about the incomplete job.
713
743
  const body = jsonBody(res);
714
744
  assert.ok(body.error.message.includes('has not completed job'));
745
+ // Wait for the async rename to complete.
746
+ await new Promise(resolve => setTimeout(resolve, 100));
715
747
  // The job should have been moved to failed.
716
748
  const failedExists = await fs.access(failedJobPath).then(() => true).catch(() => false);
717
749
  assert.ok(failedExists, 'Job should be moved to failed directory');
@@ -758,7 +790,7 @@ test('POST /worker/job with a queued job assigns it to the worker', {timeout: 50
758
790
 
759
791
  // TESTS: worker/report valid submission
760
792
 
761
- test('POST /worker/report with valid authentication and valid claimed job processes the report', async () => {
793
+ test('POST /worker/report with valid authentication and valid claimed job processes the report', {timeout: 500}, async () => {
762
794
  // Use a unique job ID that does not conflict with existing fixtures.
763
795
  const jobID = '990101T0000-tst';
764
796
  const reportPath = path.join(fixtureDBDir, 'reports', `${jobID}.json`);
@@ -851,6 +883,18 @@ for (const pagePath of htmlPagePaths) {
851
883
  });
852
884
  }
853
885
 
886
+ test('GET /enqueueForm.html shows recommendations when recs.json has entries', async () => {
887
+ await fs.writeFile(recsPath, JSON.stringify({
888
+ 'https://example.com/enqueue-test': [
889
+ {what: 'Enqueue Test Page', why: 'Needs testing for accessibility'}
890
+ ]
891
+ }));
892
+ const res = await request('GET', '/enqueueForm.html');
893
+ assert.equal(res.statusCode, 200);
894
+ assert.ok(res.body.includes('https://example.com/enqueue-test'));
895
+ assert.ok(res.body.includes('Enqueue Test Page'));
896
+ });
897
+
854
898
  test('GET /listRules.html returns an error page when called without arguments', async () => {
855
899
  const res = await request('GET', '/listRules.html');
856
900
  assert.equal(res.statusCode, 400);
@@ -891,7 +935,7 @@ test('POST /mcp returns a response from the MCP handler', async () => {
891
935
 
892
936
  // TESTS: answer error branches
893
937
 
894
- test('POST /requestTest.html with valid format but duplicate URL returns an answer error', async () => {
938
+ test('POST /requestTest.html with valid format but duplicate URL returns an answer error', {timeout: 500}, async () => {
895
939
  await fs.writeFile(recsPath, '{}\n');
896
940
  // First request to create the recommendation.
897
941
  await formRequest('POST', '/requestTest.html', {
@@ -909,7 +953,7 @@ test('POST /requestTest.html with valid format but duplicate URL returns an answ
909
953
  assert.ok(res.body.includes('Duplicate recommendation'));
910
954
  });
911
955
 
912
- test('POST /requestRetest.html with valid format but duplicate retest returns an answer error', async () => {
956
+ test('POST /requestRetest.html with valid format but duplicate retest returns an answer error', {timeout: 500}, async () => {
913
957
  await fs.writeFile(recsPath, '{}\n');
914
958
  // First retest to create the recommendation.
915
959
  await formRequest('POST', '/requestRetest.html/260202T0000/new', {
@@ -923,7 +967,7 @@ test('POST /requestRetest.html with valid format but duplicate retest returns an
923
967
  assert.ok(res.body.includes('Duplicate recommendation'));
924
968
  });
925
969
 
926
- test('POST /recAction.html with valid auth code and approval of a duplicate returns an error', async () => {
970
+ test('POST /recAction.html with valid auth code and approval of a duplicate returns an error', {timeout: 500}, async () => {
927
971
  await fs.writeFile(recsPath, '{}\n');
928
972
  // Create a recommendation.
929
973
  await formRequest('POST', '/requestTest.html', {
@@ -1026,12 +1070,21 @@ test('getAbuseError uses unknown IP when no forwarding header or remote address'
1026
1070
 
1027
1071
  // UNIT TESTS FOR startServer
1028
1072
 
1029
- test('startServer starts an HTTP server when protocol is http', async () => {
1073
+ test('startServer starts an HTTP server when protocol is http', {timeout: 500}, async () => {
1030
1074
  // Delete PROTOCOL to exercise the 'http' fallback in startServer.
1031
1075
  const savedProtocol = process.env.PROTOCOL;
1076
+ // Use port 0 so the OS assigns an ephemeral port, avoiding collisions with production.
1077
+ const savedPort = process.env.PORT;
1078
+ process.env.PORT = '0';
1032
1079
  delete process.env.PROTOCOL;
1033
1080
  const server = await startServer();
1034
1081
  process.env.PROTOCOL = savedProtocol;
1082
+ if (savedPort !== undefined) {
1083
+ process.env.PORT = savedPort;
1084
+ }
1085
+ else {
1086
+ delete process.env.PORT;
1087
+ }
1035
1088
  try {
1036
1089
  assert.ok(server);
1037
1090
  assert.equal(typeof server.listen, 'function');
@@ -1054,7 +1107,57 @@ test('startServer starts an HTTP server when protocol is http', async () => {
1054
1107
  }
1055
1108
  });
1056
1109
 
1057
- test('startServer starts an HTTPS server when protocol is https', {timeout: 500}, async () => {
1110
+ test('serve uses the default port 3000 when PORT is not set', {timeout: 500}, async () => {
1111
+ // Exercise the '|| 3000' fallback branch in serve by deleting PORT and calling
1112
+ // serve directly. The server will attempt to bind to port 3000. On a system
1113
+ // where port 3000 is free, it succeeds and we close it. Where it is occupied,
1114
+ // the error event fires and we accept that as proof the fallback was reached.
1115
+ const savedPort = process.env.PORT;
1116
+ delete process.env.PORT;
1117
+ try {
1118
+ const server = await indexModule.serve(http, {});
1119
+ let bindError = null;
1120
+ server.on('error', error => {
1121
+ bindError = error;
1122
+ });
1123
+ // Wait briefly for either successful binding or an EADDRINUSE error.
1124
+ await new Promise(resolve => {
1125
+ const timer = setTimeout(resolve, 200);
1126
+ server.on('listening', () => {
1127
+ clearTimeout(timer);
1128
+ resolve();
1129
+ });
1130
+ });
1131
+ if (bindError) {
1132
+ assert.equal(bindError.code, 'EADDRINUSE', `Expected EADDRINUSE, got: ${bindError.message}`);
1133
+ }
1134
+ else {
1135
+ const address = server.address();
1136
+ assert.ok(typeof address === 'object' && address.port === 3000);
1137
+ }
1138
+ server.closeAllConnections?.();
1139
+ await new Promise(resolve => {
1140
+ const timer = setTimeout(() => {
1141
+ server.closeAllConnections?.();
1142
+ resolve();
1143
+ }, 1000);
1144
+ server.close(() => {
1145
+ clearTimeout(timer);
1146
+ resolve();
1147
+ });
1148
+ });
1149
+ }
1150
+ finally {
1151
+ if (savedPort !== undefined) {
1152
+ process.env.PORT = savedPort;
1153
+ }
1154
+ else {
1155
+ delete process.env.PORT;
1156
+ }
1157
+ }
1158
+ });
1159
+
1160
+ test('startServer starts an HTTPS server when protocol is https', {timeout: 2000}, async () => {
1058
1161
  // Generate a self-signed certificate for the test using openssl.
1059
1162
  const {execSync} = require('node:child_process');
1060
1163
  const os = require('node:os');
@@ -1070,6 +1173,9 @@ test('startServer starts an HTTPS server when protocol is https', {timeout: 500}
1070
1173
  const savedProtocol = process.env.PROTOCOL;
1071
1174
  const savedKey = process.env.KEY;
1072
1175
  const savedCert = process.env.CERT;
1176
+ // Use port 0 so the OS assigns an ephemeral port, avoiding collisions with production.
1177
+ const savedPort = process.env.PORT;
1178
+ process.env.PORT = '0';
1073
1179
  process.env.PROTOCOL = 'https';
1074
1180
  process.env.KEY = keyPath;
1075
1181
  process.env.CERT = certPath;
@@ -1110,6 +1216,12 @@ test('startServer starts an HTTPS server when protocol is https', {timeout: 500}
1110
1216
  else {
1111
1217
  delete process.env.CERT;
1112
1218
  }
1219
+ if (savedPort !== undefined) {
1220
+ process.env.PORT = savedPort;
1221
+ }
1222
+ else {
1223
+ delete process.env.PORT;
1224
+ }
1113
1225
  fsSync.rmSync(tmpDir, {recursive: true, force: true});
1114
1226
  }
1115
1227
  });
package/llms-full.txt CHANGED
@@ -172,6 +172,7 @@ More information about Kilotest features and internals:
172
172
  - [Tutorial, “Accessibility testing strategies”](https://kilotest.com/tutorial.html)
173
173
  - [Tutorial on authorizing language models to use Kilotest](https://kilotest.com/qai/)
174
174
  - [README file](https://github.com/jrpool/kilotest/blob/main/README.md)
175
+ - [Summary of Kilotest capabilities](https://kilotest.com/capability.md)
175
176
  - [Testaro rule-engine orchestrator](https://github.com/YRA-Tech/testaro)
176
177
  - [Testaro rule classifier](https://github.com/YRA-Tech/testaro-issues)
177
178
  - [Research report on Testaro](https://arxiv.org/abs/2309.10167)
package/llms.txt CHANGED
@@ -33,6 +33,7 @@ Kilotest helps language models and AI platforms to give thorough, accurate and t
33
33
  ## More documents
34
34
 
35
35
  - [Detailed explanation of Kilotest and its capabilities for LLMs](https://kilotest.com/llms-full.txt)
36
+ - [Summary of Kilotest capabilities](https://kilotest.com/capability.md)
36
37
  - [OpenAPI specification for Kilotest](https://kilotest.com/openapi.yaml)
37
38
  - [Kilotest as connector to and collection of AI Tools](https://github.com/jrpool/kilotest/blob/main/docs/AI-TOOLS.md)
38
39
  - [Tutorial on authorizing language models to use Kilotest](https://kilotest.com/qai/)
package/package.json CHANGED
@@ -1,16 +1,17 @@
1
1
  {
2
2
  "name": "@jrpool/kilotest",
3
- "version": "46.0.14",
3
+ "version": "46.1.2",
4
4
  "description": "Ensemble testing for front-end web quality",
5
5
  "main": "index.js",
6
6
  "engines": {
7
- "node": ">=22.12.0"
7
+ "node": ">=24.16.0"
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node index",
11
- "lint": "npx eslint && npx markdownlint-cli2",
12
- "test": "c8 node --require ./test/setup.cjs --test --test-concurrency=1 --test-timeout=70",
11
+ "lint": "npx eslint && out=$(npx markdownlint-cli2 2>&1); code=$?; if [ $code -ne 0 ]; then echo \"$out\"; exit $code; fi",
12
+ "test": "c8 node --require ./test/setup.cjs --test --test-concurrency=1 --test-timeout=100 --test-randomize",
13
13
  "test:smoke": "node smokeTest",
14
+ "checkinfo": "node checkinfo",
14
15
  "generate:openapi": "node scripts/generate-openapi"
15
16
  },
16
17
  "repository": {
package/smokeTest.js CHANGED
@@ -14,50 +14,53 @@ const host = process.env.SMOKE_HOST || 'kilotest.com';
14
14
  // Concrete paths matching each wildcard pattern in the routes table, for smoke testing.
15
15
  const concretePaths = {
16
16
  GET: {
17
- '/mcp': '/mcp',
17
+ '*.html*': '/listReports.html',
18
18
  '/': '/',
19
- '/index.html': '/index.html',
20
- '/robots.txt': '/robots.txt',
21
- '/openapi.yaml': '/openapi.yaml',
22
- '/openapi.json': '/openapi.json',
23
- '/swagger.yaml': '/swagger.yaml',
24
- '/swagger.json': '/swagger.json',
25
19
  '/api-docs': '/api-docs',
26
- '/llms.txt': '/llms.txt',
20
+ '/api/*': '/api/listReports',
21
+ '/capability.md': '/capability.md',
22
+ '/favicon.*': '/favicon.ico',
23
+ '/fullReport.json/*': '/fullReport.json/260101T0000/mix',
24
+ '/index.html': '/index.html',
27
25
  '/llms-full.txt': '/llms-full.txt',
26
+ '/llms.txt': '/llms.txt',
27
+ '/mcp': '/mcp',
28
+ '/openapi.json': '/openapi.json',
29
+ '/openapi.yaml': '/openapi.yaml',
30
+ '/robots.txt': '/robots.txt',
28
31
  '/sitemap.xml': '/sitemap.xml',
29
32
  '/style.css': '/style.css',
30
- '/fullReport.json/*': '/fullReport.json/260101T0000/mix',
31
- '/api/*': '/api/listReports',
32
- '/tutorial/images/*': '/tutorial/images/diagram.png',
33
- '/favicon.*': '/favicon.ico',
34
- '*.html*': '/listReports.html'
33
+ '/swagger.json': '/swagger.json',
34
+ '/swagger.yaml': '/swagger.yaml',
35
+ '/tutorial/images/*': '/tutorial/images/diagram.png'
35
36
  },
36
37
  POST: {
38
+ '/api/*': '/api/requestFeature',
37
39
  '/mcp': '/mcp',
38
- '/requestTest.html': '/requestTest.html',
39
- '/requestRetest.html/*': '/requestRetest.html/260101T0001/ct',
40
- '/recAction.html': '/recAction.html',
41
40
  '/reannotate.html': '/reannotate.html',
41
+ '/recAction.html': '/recAction.html',
42
42
  '/renewWCAG.html': '/renewWCAG.html',
43
+ '/requestRetest.html/*': '/requestRetest.html/260101T0001/ct',
44
+ '/requestTest.html': '/requestTest.html',
45
+ '/tutorialComment.html': '/tutorialComment.html',
43
46
  '/worker/job': '/worker/job',
44
- '/worker/report': '/worker/report',
45
- '/api/*': '/api/requestFeature',
46
- '/tutorialComment.html': '/tutorialComment.html'
47
+ '/worker/report': '/worker/report'
47
48
  }
48
49
  };
49
50
  // Minimal POST bodies for paths that require them.
50
51
  const postBodies = {
51
52
  '/api/requestFeature': {feature: 'smoke test'},
52
- '/requestTest.html': {what: 'Smoke Test Page', url: 'https://smoketest.example.com', why: 'smoke test'},
53
- '/requestRetest.html/260101T0001/ct': {why: 'smoke test'},
54
- '/recAction.html': {target: 'https://smoketest.example.com\tSmoke Test Page', authCode: 'invalid'},
53
+ '/mcp': {},
55
54
  '/reannotate.html': {authCode: 'invalid'},
55
+ '/recAction.html': {
56
+ target: 'https://smoketest.example.com\tSmoke Test Page', authCode: 'invalid'
57
+ },
56
58
  '/renewWCAG.html': {authCode: 'invalid'},
57
- '/worker/job': {},
58
- '/worker/report': {},
59
+ '/requestRetest.html/260101T0001/ct': {why: 'smoke test'},
60
+ '/requestTest.html': {what: 'Smoke Test Page', url: 'https://smoketest.example.com', why: 'smoke test'},
59
61
  '/tutorialComment.html': {content: 'smoke test'},
60
- '/mcp': {}
62
+ '/worker/job': {},
63
+ '/worker/report': {}
61
64
  };
62
65
 
63
66
  // FUNCTIONS
@@ -65,14 +68,16 @@ const postBodies = {
65
68
  // Sends an HTTPS request and returns the status code and body length.
66
69
  const sendRequest = (method, requestPath) => new Promise((resolve, reject) => {
67
70
  const body = method === 'POST' ? JSON.stringify(postBodies[requestPath] || {}) : null;
71
+ const headers = {'x-kilotest-smoke': '1'};
72
+ if (body) {
73
+ headers['content-type'] = 'application/json; charset=utf-8';
74
+ headers['content-length'] = Buffer.byteLength(body);
75
+ }
68
76
  const options = {
69
77
  method,
70
78
  host,
71
79
  path: requestPath,
72
- headers: body ? {
73
- 'content-type': 'application/json; charset=utf-8',
74
- 'content-length': Buffer.byteLength(body)
75
- } : {}
80
+ headers
76
81
  };
77
82
  const req = https.request(options, response => {
78
83
  const chunks = [];
package/util.js CHANGED
@@ -673,12 +673,14 @@ exports.isHidden = async (timeStamp, jobID) => {
673
673
  // Returns an extract of an available report, or an error object if it cannot be read or parsed.
674
674
  const getReportExtract = exports.getReportExtract = async (timeStamp, jobID) => {
675
675
  try {
676
+ // Get the report.
676
677
  const reportJSON = await fs.readFile(
677
678
  path.join(reportsPath(), `${timeStamp}-${jobID}.json`), 'utf8'
678
679
  );
679
680
  const report = JSON.parse(reportJSON);
680
681
  const {target, jobData} = report;
681
682
  const {what, url} = target;
683
+ // Return an extract of it.
682
684
  return {
683
685
  timeStamp,
684
686
  jobID,
@@ -695,12 +697,17 @@ const getReportExtract = exports.getReportExtract = async (timeStamp, jobID) =>
695
697
  };
696
698
  // Returns extracts of all available reports.
697
699
  const getReportExtracts = exports.getReportExtracts = async () => {
700
+ // Get the names of the available report files.
698
701
  const reportFileNames = await fs.readdir(reportsPath());
702
+ // Initialize an array of extracts.
699
703
  const extracts = [];
704
+ // For each one:
700
705
  for (const reportFileName of reportFileNames) {
701
706
  const [timeStamp, jobID] = reportFileName.slice(0, -5).split('-');
707
+ // Get an extract of it.
702
708
  const extract = await getReportExtract(timeStamp, jobID);
703
709
  if (!extract.error) {
710
+ // Add the extract to the array.
704
711
  extracts.push(extract);
705
712
  }
706
713
  }
@@ -715,11 +722,15 @@ exports.isReportAvailable = async (what, url) => {
715
722
  };
716
723
  // Gets extracts of the latest available reports for all page descriptions.
717
724
  exports.getLatestReportExtracts = async () => {
725
+ // Get extracts of all available reports.
718
726
  const reportExtracts = await getReportExtracts();
727
+ // Sort them by page description and, secondarily, completion time.
719
728
  objectSort(reportExtracts, 'reportTime', 'alpha');
720
729
  objectSort(reportExtracts, 'what', 'alpha');
730
+ // Get those that are not superseded.
721
731
  const latestReportExtracts = reportExtracts
722
732
  .filter((extract, index) => extract.what !== reportExtracts[index + 1]?.what);
733
+ // Return them.
723
734
  return latestReportExtracts;
724
735
  };
725
736
  // Gets the descriptions of multi-report pages.
package/util.test.js CHANGED
@@ -398,7 +398,7 @@ test('processTestRequest returns an error for a mismatched directory name', asyn
398
398
  assert.equal(result.message, 'Invalid recommendation');
399
399
  });
400
400
 
401
- test('processTestRequest succeeds and populates the template for a valid request', async () => {
401
+ test('processTestRequest succeeds and populates the template for a valid request', {timeout: 500}, async () => {
402
402
  // Reset recs.json to empty before the test.
403
403
  await fs.writeFile(recsPath(), '{}\n');
404
404
  const result = await processTestRequest('test', requestTestDir, 'Example Page', 'https://example.com', 'because accessibility');
@@ -409,7 +409,7 @@ test('processTestRequest succeeds and populates the template for a valid request
409
409
  await fs.writeFile(recsPath(), '{}\n');
410
410
  });
411
411
 
412
- test('processTestRequest returns a duplicate error for a repeated request', async () => {
412
+ test('processTestRequest returns a duplicate error for a repeated request', {timeout: 500}, async () => {
413
413
  // Reset recs.json to empty, then make a successful request.
414
414
  await fs.writeFile(recsPath(), '{}\n');
415
415
  await processTestRequest('test', requestTestDir, 'Example Page', 'https://example.com', 'because accessibility');
@@ -88,7 +88,7 @@ test('listReports includes a link to request testing a new page', async () => {
88
88
  assert.ok(testLink);
89
89
  });
90
90
 
91
- test('listReports shows recommendations when recs.json has entries', async () => {
91
+ test('listReports shows recommendations when recs.json has entries', {timeout: 500}, async () => {
92
92
  const dbDir = path.join(__dirname, '..', '..', 'test', 'fixtures', 'db');
93
93
  const recsPath = path.join(dbDir, 'jobs', 'recs.json');
94
94
  const originalRecs = await fs.readFile(recsPath, 'utf8');
@@ -87,7 +87,7 @@ test('answer returns an error when no reports are available', async () => {
87
87
  }
88
88
  });
89
89
 
90
- test('answer returns an error when a report fails annotation', async () => {
90
+ test('answer returns an error when a report fails annotation', {timeout: 2000}, async () => {
91
91
  // Back up all fixture reports, because annotateReport modifies good reports
92
92
  // in place before it reaches the bad one and returns an error.
93
93
  const backups = await backupReports();
@@ -122,7 +122,7 @@ test('answer returns an error when a report fails annotation', async () => {
122
122
  }
123
123
  });
124
124
 
125
- test('answer returns ok with an answer page when all reports annotate successfully', async () => {
125
+ test('answer returns ok with an answer page when all reports annotate successfully', {timeout: 2000}, async () => {
126
126
  // Back up all fixture reports, because annotateReport modifies them in place.
127
127
  const backups = await backupReports();
128
128
  try {
@@ -61,7 +61,7 @@ after(async () => {
61
61
 
62
62
  // TESTS
63
63
 
64
- test('answer returns ok with a populated answer page for a valid test request', async () => {
64
+ test('answer returns ok with a populated answer page for a valid test request', {timeout: 500}, async () => {
65
65
  // Require the module after DB_DIR is set.
66
66
  const {answer} = require('./index');
67
67
  const result = await answer(
@@ -81,7 +81,7 @@ test('answer returns ok with a populated answer page for a valid test request',
81
81
  assert.ok(h1.textContent.includes('Test Page'));
82
82
  });
83
83
 
84
- test('answer returns an error when the URL is already queued', async () => {
84
+ test('answer returns an error when the URL is already queued', {timeout: 500}, async () => {
85
85
  const {answer} = require('./index');
86
86
  // Create a queued job with the target URL.
87
87
  const queueDir = path.join(fixtureDBDir, 'jobs', 'queue');
@@ -107,7 +107,7 @@ test('answer returns an error when the URL is already queued', async () => {
107
107
  }
108
108
  });
109
109
 
110
- test('answer returns an error for an invalid recommendation', async () => {
110
+ test('answer returns an error for an invalid recommendation', {timeout: 500}, async () => {
111
111
  const {answer} = require('./index');
112
112
  const result = await answer('', 'not-a-url', 'why');
113
113
  assert.equal(result.status, 'error');
@@ -72,7 +72,7 @@ test('rewindReportsForm deletes a latest superseding report with valid auth code
72
72
  }
73
73
  });
74
74
 
75
- test('rewindReportsForm returns an error when a report file is corrupt', async () => {
75
+ test('rewindReportsForm returns an error when a report file is corrupt', {timeout: 500}, async () => {
76
76
  const reportPath = path.join(reportsPath(), '260101T0001-ct.json');
77
77
  const backup = await fs.readFile(reportPath, 'utf8');
78
78
  try {
@@ -59,7 +59,7 @@ test('handleComment returns an error for content that is empty after sanitizatio
59
59
  assert.equal(result.message, 'Comment is empty after sanitization');
60
60
  });
61
61
 
62
- test('handleComment saves a sanitized comment and returns ok', async () => {
62
+ test('handleComment saves a sanitized comment and returns ok', {timeout: 500}, async () => {
63
63
  // Replace comments with an empty array for a clean test.
64
64
  await fs.writeFile(commentsPath, '[]\n');
65
65
  const result = await handleComment('This is a <b>test</b> comment.');
@@ -70,7 +70,7 @@ test('handleComment saves a sanitized comment and returns ok', async () => {
70
70
  assert.ok(comments[0].timeStamp);
71
71
  });
72
72
 
73
- test('handleComment strips HTML tags and control characters', async () => {
73
+ test('handleComment strips HTML tags and control characters', {timeout: 500}, async () => {
74
74
  await fs.writeFile(commentsPath, '[]\n');
75
75
  const result = await handleComment('<img src=x onerror=alert(1)>\x00\x07Hello');
76
76
  assert.equal(result.status, 'ok');
@@ -78,7 +78,7 @@ test('handleComment strips HTML tags and control characters', async () => {
78
78
  assert.equal(comments[0].content, 'Hello');
79
79
  });
80
80
 
81
- test('handleComment truncates content to 500 characters', async () => {
81
+ test('handleComment truncates content to 500 characters', {timeout: 500}, async () => {
82
82
  await fs.writeFile(commentsPath, '[]\n');
83
83
  const longComment = 'x'.repeat(600);
84
84
  const result = await handleComment(longComment);
@@ -87,7 +87,7 @@ test('handleComment truncates content to 500 characters', async () => {
87
87
  assert.equal(comments[0].content.length, 500);
88
88
  });
89
89
 
90
- test('handleComment creates comments.json when it does not exist', async () => {
90
+ test('handleComment creates comments.json when it does not exist', {timeout: 500}, async () => {
91
91
  await fs.unlink(commentsPath).catch(() => {});
92
92
  const result = await handleComment('Test comment for missing file');
93
93
  assert.equal(result.status, 'ok');