@jrpool/kilotest 48.0.0 → 50.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.
Files changed (54) hide show
  1. package/.claude/scheduled_tasks.lock +1 -0
  2. package/api/getReport.ts +3 -1
  3. package/api/requestFeature.test.ts +23 -3
  4. package/api/requestFeature.ts +19 -11
  5. package/api/requestRetest.test.ts +39 -13
  6. package/api/requestRetest.ts +11 -5
  7. package/api/requestTest.ts +6 -6
  8. package/api/schemas.ts +2 -2
  9. package/docs/BACKLOG.md +232 -12
  10. package/docs/SERVICE.md +84 -3
  11. package/index.test.ts +443 -20
  12. package/index.ts +246 -27
  13. package/linkValidation.test.ts +1 -0
  14. package/mcp.test.ts +31 -1
  15. package/mcp.ts +9 -0
  16. package/package.json +1 -1
  17. package/smokeTest.ts +16 -0
  18. package/test/dbFixture.ts +8 -0
  19. package/util.test.ts +295 -0
  20. package/util.ts +121 -0
  21. package/web/ai0BalanceForm/ai0BalanceForm.test.ts +16 -10
  22. package/web/ai0BalanceForm/index.html +1 -1
  23. package/web/ai0BalanceForm/index.ts +12 -8
  24. package/web/enqueue/index.ts +33 -39
  25. package/web/expungeReportsForm/expungeReportsForm.test.ts +19 -11
  26. package/web/expungeReportsForm/index.html +1 -1
  27. package/web/expungeReportsForm/index.ts +1 -1
  28. package/web/hideReportForm/hideReportForm.test.ts +24 -9
  29. package/web/hideReportForm/index.html +1 -1
  30. package/web/hideReportForm/index.ts +13 -8
  31. package/web/listTopIssues/index.ts +1 -1
  32. package/web/manage/index.html +2 -1
  33. package/web/metrics/index.html +19 -0
  34. package/web/metrics/index.ts +127 -0
  35. package/web/metrics/metrics.test.ts +211 -0
  36. package/web/pruneReportsForm/index.html +1 -1
  37. package/web/pruneReportsForm/index.ts +1 -1
  38. package/web/pruneReportsForm/pruneReportsForm.test.ts +18 -10
  39. package/web/reannotate/index.ts +7 -3
  40. package/web/reannotate/reannotate.test.ts +1 -1
  41. package/web/renewWCAG/index.ts +6 -3
  42. package/web/renewWCAG/renewWCAG.test.ts +1 -1
  43. package/web/reportDeletion.ts +13 -7
  44. package/web/rewindReportsForm/index.html +1 -1
  45. package/web/rewindReportsForm/index.ts +1 -1
  46. package/web/rewindReportsForm/rewindReportsForm.test.ts +18 -10
  47. package/web/showHiddenReportsForm/index.html +24 -0
  48. package/web/showHiddenReportsForm/index.ts +45 -0
  49. package/web/showHiddenReportsForm/showHiddenReportsForm.test.ts +103 -0
  50. package/web/tutorialAI/index.ts +1 -1
  51. package/web/tutorialWeb/index.ts +1 -1
  52. package/web/unhideReportForm/index.html +1 -1
  53. package/web/unhideReportForm/index.ts +13 -8
  54. package/web/unhideReportForm/unhideReportForm.test.ts +19 -11
@@ -0,0 +1 @@
1
+ {"sessionId":"f44e6b44-375e-4007-879a-19e4e5ed34a0","pid":90042,"procStart":"Sun Sep 20 03:19:03 2026","acquiredAt":1789874948676}
package/api/getReport.ts CHANGED
@@ -25,7 +25,9 @@ type ResponseContent = z.infer<typeof getReportResponseSchema>['response content
25
25
  export const response = async (args: string[]) => {
26
26
  const [timeStamp = '', jobID = ''] = args;
27
27
  const thisHost = getThisHost();
28
- // Get the report size.
28
+ // Get the report size. A malformed timestamp or job ID resolves to a nonexistent file
29
+ // path, which getReportStats already reports as unavailable, so no separate syntactic
30
+ // check is needed here.
29
31
  const reportStats = await getReportStats(timeStamp, jobID);
30
32
  // Create the response content.
31
33
  const responseContent: ResponseContent = reportStats
@@ -32,7 +32,27 @@ test('requestFeature rejects an empty feature request', async () => {
32
32
  const body = await response(['']);
33
33
  const details = body['response content']['details about your request'] as any;
34
34
  assert.ok(details.error);
35
- assert.ok(!logged.some(line => line.startsWith('WARNING (MCP feature request received)')));
35
+ assert.ok(
36
+ !logged.some(line => line.startsWith('WARNING (Kilotest: MCP feature request received)'))
37
+ );
38
+ });
39
+
40
+ test('requestFeature rejects a feature request shorter than 20 characters', async () => {
41
+ const body = await response(['Too short']);
42
+ const details = body['response content']['details about your request'] as any;
43
+ assert.ok(details.error.includes('feature description'));
44
+ assert.ok(
45
+ !logged.some(line => line.startsWith('WARNING (Kilotest: MCP feature request received)'))
46
+ );
47
+ });
48
+
49
+ test('requestFeature rejects a feature request longer than 1000 characters', async () => {
50
+ const body = await response(['x'.repeat(1001)]);
51
+ const details = body['response content']['details about your request'] as any;
52
+ assert.ok(details.error.includes('feature description'));
53
+ assert.ok(
54
+ !logged.some(line => line.startsWith('WARNING (Kilotest: MCP feature request received)'))
55
+ );
36
56
  });
37
57
 
38
58
  test('requestFeature accepts a non-empty feature request and notifies the manager', async () => {
@@ -42,13 +62,13 @@ test('requestFeature accepts a non-empty feature request and notifies the manage
42
62
  assert.ok(details['date and time received']);
43
63
  assert.equal(details.disposition, 'received and logged; manager notified');
44
64
  assert.ok(logged.some(line =>
45
- line.startsWith('WARNING (MCP feature request received)')
65
+ line.startsWith('WARNING (Kilotest: MCP feature request received)')
46
66
  && line.includes('Add a dark mode toggle')
47
67
  ));
48
68
  });
49
69
 
50
70
  test('requestFeature includes tool name and metadata', async () => {
51
- const body = await response(['Some feature']);
71
+ const body = await response(['A sufficiently long feature description']);
52
72
  assert.equal(body['tool name'], 'requestFeature');
53
73
  assert.ok(body['response metadata'].identifier);
54
74
  });
@@ -8,6 +8,7 @@
8
8
  import {z} from 'zod';
9
9
  import {getResponseMetadata, getThisHost, getToolsFacts} from './util.ts';
10
10
  import {sendAlert} from '../alerts.ts';
11
+ import {checkLength} from '../util.ts';
11
12
  import {requestFeatureResponseSchema} from './schemas.ts';
12
13
 
13
14
  // TYPES
@@ -21,20 +22,27 @@ type ResponseContent = z.infer<typeof requestFeatureResponseSchema>['response co
21
22
  export const response = async (args: string[]) => {
22
23
  const [feature = ''] = args;
23
24
  const thisHost = getThisHost();
25
+ const lengthCheck = checkLength(feature, 20, 1000, 'feature description');
24
26
  // Initialize the response content.
25
- const responseContent: ResponseContent = {
26
- 'details about your request': {
27
- error: 'request invalid: request is empty'
28
- }
29
- };
30
- // If the requested feature or improvement exists:
31
- if (feature) {
27
+ let responseContent: ResponseContent;
28
+ // If the feature description is invalid:
29
+ if (lengthCheck.status === 'error') {
30
+ responseContent = {
31
+ 'details about your request': {
32
+ error: `request invalid: ${lengthCheck.message}`
33
+ }
34
+ };
35
+ }
36
+ // Otherwise, i.e. if it is valid:
37
+ else {
32
38
  // Notify the manager.
33
- await sendAlert('MCP feature request received', feature);
39
+ await sendAlert('Kilotest: MCP feature request received', feature);
34
40
  // Add the disposition to the response content.
35
- responseContent['details about your request'] = {
36
- 'date and time received': new Date().toISOString(),
37
- disposition: 'received and logged; manager notified'
41
+ responseContent = {
42
+ 'details about your request': {
43
+ 'date and time received': new Date().toISOString(),
44
+ disposition: 'received and logged; manager notified'
45
+ }
38
46
  };
39
47
  }
40
48
  // Create a response body.
@@ -15,11 +15,29 @@ import {fixtureDBDir} from '../test/dbFixture.ts';
15
15
 
16
16
  const savedDBDir = process.env.DB_DIR;
17
17
  const testRequestsPath = path.join(fixtureDBDir, 'jobs', 'testRequests.json');
18
+ // A report added only to this test's disposable copy of the fixture database (never to
19
+ // the tracked test/fixtures/db corpus), so its 3-character job ID satisfies isJobID
20
+ // without perturbing report-count tests elsewhere that copy the whole corpus. Its job ID
21
+ // intentionally differs from the tracked corpus's 2-character 260101T0001-ct.json, which
22
+ // isJobID would reject.
23
+ const retestFixturePath = path.join(fixtureDBDir, 'reports', '260101T0003-ret.json');
24
+ const retestFixtureReport = {
25
+ id: '260101T0003-ret',
26
+ what: 'Retest Fixture Page',
27
+ target: {what: 'Retest Fixture Page', url: 'https://example.com/retestfixture'},
28
+ sources: {worker: 'test-worker'},
29
+ acts: [],
30
+ jobData: {startTime: '26-01-01T00:00', endTime: '26-01-01T00:10', elapsedSeconds: 600, preventions: {}, issuelessRules: []},
31
+ catalog: {},
32
+ images: {},
33
+ checkpoints: []
34
+ };
18
35
  let logged: any[] = [];
19
36
  const originalLog = console.log;
20
37
 
21
- before(() => {
38
+ before(async () => {
22
39
  process.env.DB_DIR = fixtureDBDir;
40
+ await fs.writeFile(retestFixturePath, JSON.stringify(retestFixtureReport));
23
41
  });
24
42
 
25
43
  // Reset the test-requests file and capture console.log, so the alert that
@@ -32,8 +50,9 @@ beforeEach(async () => {
32
50
 
33
51
  import {response} from './requestRetest.ts';
34
52
 
35
- after(() => {
53
+ after(async () => {
36
54
  console.log = originalLog;
55
+ await fs.unlink(retestFixturePath);
37
56
  if (savedDBDir !== undefined) {
38
57
  process.env.DB_DIR = savedDBDir;
39
58
  }
@@ -45,12 +64,19 @@ after(() => {
45
64
  // TESTS
46
65
 
47
66
  test('requestRetest rejects a nonexistent report', async () => {
48
- const body = await response(['999999T9999', 'xyz', 'A reason that is long enough.']);
67
+ const body = await response(['251231T0000', 'zzz', 'A reason that is long enough.']);
49
68
  const details = body['response content']['details about your request'] as any;
50
69
  assert.ok(details.error.includes('does not exist'));
51
70
  assert.ok(!logged.some(line => line.includes('new retest request awaits approval')));
52
71
  });
53
72
 
73
+ test('requestRetest rejects a malformed timestamp or job identifier', async () => {
74
+ const body = await response(['999999T9999', 'xyz', 'A reason that is long enough.']);
75
+ const details = body['response content']['details about your request'] as any;
76
+ assert.ok(details.error.includes('malformed'));
77
+ assert.ok(!logged.some(line => line.includes('new retest request awaits approval')));
78
+ });
79
+
54
80
  test('requestRetest rejects a superseded report', async () => {
55
81
  // 260101T0000-mix is an earlier report of "Mixed Outcomes Page" than 260202T0000-new,
56
82
  // so it is superseded and processTestRequest returns 'superseded', reported via the
@@ -66,7 +92,7 @@ test('requestRetest rejects a superseded report', async () => {
66
92
  });
67
93
 
68
94
  test('requestRetest rejects a reason shorter than 20 characters', async () => {
69
- const body = await response(['260101T0001', 'ct', 'short']);
95
+ const body = await response(['260101T0003', 'ret', 'short']);
70
96
  const details = body['response content']['details about your request'] as any;
71
97
  assert.ok(details.error.includes('reason'));
72
98
  assert.ok(!logged.some(line => line.includes('new retest request awaits approval')));
@@ -74,20 +100,20 @@ test('requestRetest rejects a reason shorter than 20 characters', async () => {
74
100
 
75
101
  test('requestRetest rejects a reason longer than 100 characters', async () => {
76
102
  const longReason = 'x'.repeat(101);
77
- const body = await response(['260101T0001', 'ct', longReason]);
103
+ const body = await response(['260101T0003', 'ret', longReason]);
78
104
  const details = body['response content']['details about your request'] as any;
79
105
  assert.ok(details.error.includes('reason'));
80
106
  assert.ok(!logged.some(line => line.includes('new retest request awaits approval')));
81
107
  });
82
108
 
83
109
  test('requestRetest rejects a report matching a claimed job by description', async () => {
84
- // 260101T0001-ct is the "All CantTell Page" report.
110
+ // 260101T0003-ret is the "Retest Fixture Page" report.
85
111
  const claimedPath = path.join(fixtureDBDir, 'jobs', 'claimed', 'clm.json');
86
112
  await fs.writeFile(claimedPath, JSON.stringify({
87
- target: {what: 'All CantTell Page', url: 'https://example.com/unrelated'}
113
+ target: {what: 'Retest Fixture Page', url: 'https://example.com/unrelated'}
88
114
  }));
89
115
  try {
90
- const body = await response(['260101T0001', 'ct', 'A reason that is long enough.']);
116
+ const body = await response(['260101T0003', 'ret', 'A reason that is long enough.']);
91
117
  const details = body['response content']['details about your request'] as any;
92
118
  assert.equal(details.error, undefined);
93
119
  const disposition = body['response content']['disposition of your request'] as any;
@@ -102,13 +128,13 @@ test('requestRetest rejects a report matching a claimed job by description', asy
102
128
  });
103
129
 
104
130
  test('requestRetest rejects a report matching a queued job by URL', async () => {
105
- // 260101T0001-ct's URL is https://example.com/canttell.
131
+ // 260101T0003-ret's URL is https://example.com/retestfixture.
106
132
  const queuedPath = path.join(fixtureDBDir, 'jobs', 'queue', 'que.json');
107
133
  await fs.writeFile(queuedPath, JSON.stringify({
108
- target: {what: 'Some Other Page', url: 'https://example.com/canttell'}
134
+ target: {what: 'Some Other Page', url: 'https://example.com/retestfixture'}
109
135
  }));
110
136
  try {
111
- const body = await response(['260101T0001', 'ct', 'A reason that is long enough.']);
137
+ const body = await response(['260101T0003', 'ret', 'A reason that is long enough.']);
112
138
  const details = body['response content']['details about your request'] as any;
113
139
  assert.equal(details.error, undefined);
114
140
  const disposition = body['response content']['disposition of your request'] as any;
@@ -123,8 +149,8 @@ test('requestRetest rejects a report matching a queued job by URL', async () =>
123
149
  });
124
150
 
125
151
  test('requestRetest rejects a duplicate request', async () => {
126
- await response(['260101T0001', 'ct', 'A reason that is long enough.']);
127
- const body = await response(['260101T0001', 'ct', 'A reason that is long enough.']);
152
+ await response(['260101T0003', 'ret', 'A reason that is long enough.']);
153
+ const body = await response(['260101T0003', 'ret', 'A reason that is long enough.']);
128
154
  const details = body['response content']['details about your request'] as any;
129
155
  assert.equal(details.error, undefined);
130
156
  const disposition = body['response content']['disposition of your request'] as any;
@@ -7,7 +7,7 @@
7
7
 
8
8
  import {z} from 'zod';
9
9
  import {getResponseMetadata, getThisHost, getToolsFacts} from './util.ts';
10
- import {processTestRequest} from '../util.ts';
10
+ import {checkLength, isJobID, isTimeStamp, processTestRequest} from '../util.ts';
11
11
  import {requestRetestResponseSchema} from './schemas.ts';
12
12
 
13
13
  // TYPES
@@ -21,14 +21,20 @@ type ResponseContent = z.infer<typeof requestRetestResponseSchema>['response con
21
21
  export const response = async (args: string[]) => {
22
22
  const [timeStamp = '', jobID = '', reason = ''] = args;
23
23
  const thisHost = getThisHost();
24
- const reasonLength = reason.length;
24
+ const reasonCheck = checkLength(reason, 20, 100, 'reason');
25
25
  // Initialize the response-content properties.
26
26
  let requestDetails: ResponseContent['details about your request'];
27
27
  let requestDisposition: ResponseContent['disposition of your request'] = null;
28
- // If the encoded reason is too short or too long:
29
- if (reasonLength < 20 || reasonLength > 100) {
28
+ // If the timestamp or job ID is not syntactically valid:
29
+ if (!isTimeStamp(timeStamp) || !isJobID(jobID)) {
30
30
  requestDetails = {
31
- error: 'request invalid: your reason is not between 20 and 100 characters long'
31
+ error: 'request invalid: the report timestamp or job identifier is malformed'
32
+ };
33
+ }
34
+ // Otherwise, if the encoded reason is too short or too long:
35
+ else if (reasonCheck.status === 'error') {
36
+ requestDetails = {
37
+ error: `request invalid: your ${reasonCheck.message}`
32
38
  }
33
39
  }
34
40
  // Otherwise, i.e. if the request is facially valid:
@@ -7,7 +7,7 @@
7
7
 
8
8
  import {z} from 'zod';
9
9
  import {getResponseMetadata, getThisHost, getToolsFacts} from './util.ts';
10
- import {isURL, processTestRequest} from '../util.ts';
10
+ import {checkLength, isURL, processTestRequest} from '../util.ts';
11
11
  import {requestTestResponseSchema} from './schemas.ts';
12
12
 
13
13
  // TYPES
@@ -24,14 +24,14 @@ export const response = async (args: string[]) => {
24
24
  // Initialize the response-content properties.
25
25
  let requestDetails: ResponseContent['details about your request'];
26
26
  let requestDisposition: ResponseContent['disposition of your request'] = null;
27
- const descriptionLength = description.length;
28
- // If the description is empty or too long:
29
- if (!descriptionLength || descriptionLength > 100) {
27
+ const descriptionCheck = checkLength(description, 1, 100, 'description of the page');
28
+ // If the description is invalid:
29
+ if (descriptionCheck.status === 'error') {
30
30
  requestDetails = {
31
- error: 'request invalid: your description of the page is not between 1 and 100 characters long'
31
+ error: `request invalid: your ${descriptionCheck.message}`
32
32
  };
33
33
  }
34
- // Otherwise, i.e. if the URL is too short or too long::
34
+ // Otherwise, i.e. if the URL is too short or too long:
35
35
  else if (url.length < 12 || url.length > 300) {
36
36
  requestDetails = {
37
37
  error: 'request invalid: you specified a URL for the page that is not between 12 and 300 characters long'
package/api/schemas.ts CHANGED
@@ -43,7 +43,7 @@ export const getReportSchema = {
43
43
 
44
44
  // requestTest: POST /api/requestTest
45
45
  export const requestTestSchema = {
46
- description: z.string().describe('10- to 100-character description of the page conforming to the naming convention used in the listReports output'),
46
+ description: z.string().describe('1- to 100-character description of the page conforming to the naming convention used in the listReports output'),
47
47
  URL: z.string().describe('12- to 300-character URL of the page, including the https:// scheme and any query'),
48
48
  reason: z.string().describe('20- to 100-character reason why the page should be tested')
49
49
  };
@@ -57,7 +57,7 @@ export const requestRetestSchema = {
57
57
 
58
58
  // requestFeature: POST /api/requestFeature
59
59
  export const requestFeatureSchema = {
60
- feature: z.string().describe('description of requested feature improvement or new feature')
60
+ feature: z.string().describe('20- to 1000-character description of requested feature improvement or new feature')
61
61
  };
62
62
 
63
63
  // listReports takes no input; omitted (mcp.ts already uses inputSchema: {}).