@jrpool/kilotest 52.0.0 → 57.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 (59) hide show
  1. package/.devin/config.local.json +4 -1
  2. package/.github/workflows/ci.yml +3 -0
  3. package/README.md +1 -1
  4. package/api/awaitTest.test.ts +114 -0
  5. package/api/awaitTest.ts +108 -0
  6. package/api/listReports.ts +2 -2
  7. package/api/orderNewTest.test.ts +213 -0
  8. package/api/orderNewTest.ts +145 -0
  9. package/api/orderRetest.test.ts +179 -0
  10. package/api/orderRetest.ts +119 -0
  11. package/api/requestFeature.test.ts +36 -0
  12. package/api/requestFeature.ts +26 -4
  13. package/api/{requestTest.test.ts → requestNewTest.test.ts} +57 -23
  14. package/api/{requestTest.ts → requestNewTest.ts} +31 -36
  15. package/api/requestRetest.test.ts +24 -6
  16. package/api/requestRetest.ts +10 -29
  17. package/api/routes.test.ts +7 -4
  18. package/api/routes.ts +45 -7
  19. package/api/schemas.ts +120 -10
  20. package/api/util.ts +59 -2
  21. package/api/version.ts +6 -2
  22. package/capability.md +9 -6
  23. package/docs/AI-TOOLS.md +3 -3
  24. package/docs/SERVICE.md +19 -8
  25. package/env.example +39 -0
  26. package/index.test.ts +146 -24
  27. package/index.ts +129 -36
  28. package/lhm.plugin.json +1 -1
  29. package/linkValidation.test.ts +2 -2
  30. package/llms-full.txt +35 -4
  31. package/llms.txt +6 -3
  32. package/mcp.test.ts +109 -12
  33. package/mcp.ts +146 -26
  34. package/memory/MEMORY.md +1 -0
  35. package/memory/plan_issue_111_instant_test.md +198 -0
  36. package/openapi.yaml +456 -9
  37. package/package.json +2 -1
  38. package/routes.test.ts +1 -1
  39. package/routing.test.ts +2 -2
  40. package/scripts/generate-openapi.ts +1 -1
  41. package/server.json +1 -1
  42. package/sitemap.xml +1 -1
  43. package/smokeTest.ts +2 -2
  44. package/util.test.ts +486 -2
  45. package/util.ts +276 -45
  46. package/web/listReports/index.html +1 -1
  47. package/web/listReports/listReports.test.ts +1 -1
  48. package/web/{requestTest → requestNewTest}/index.ts +10 -1
  49. package/web/{requestTest/requestTest.test.ts → requestNewTest/requestNewTest.test.ts} +18 -2
  50. package/web/{requestTestForm → requestNewTestForm}/index.html +1 -1
  51. package/web/{requestTestForm/requestTestForm.test.ts → requestNewTestForm/requestNewTestForm.test.ts} +6 -6
  52. package/web/requestRetest/index.ts +9 -0
  53. package/web/requestRetest/requestRetest.test.ts +14 -0
  54. package/web/tutorialAI/index.ts +25 -3
  55. package/web/tutorialAI/tutorialAI.test.ts +32 -0
  56. package/web/tutorialWeb/index.ts +25 -3
  57. package/web/tutorialWeb/tutorialWeb.test.ts +32 -0
  58. /package/web/{requestTest → requestNewTest}/index.html +0 -0
  59. /package/web/{requestTestForm → requestNewTestForm}/index.ts +0 -0
package/util.ts CHANGED
@@ -14,6 +14,7 @@ import type {Act, Catalog, Report, StandardInstance} from 'testaro';
14
14
  import crypto from 'node:crypto';
15
15
  import dns from 'node:dns/promises';
16
16
  import fs from 'node:fs/promises';
17
+ import ipaddr from 'ipaddr.js';
17
18
  import net from 'node:net';
18
19
  import path from 'node:path';
19
20
  import querystring from 'node:querystring';
@@ -77,9 +78,13 @@ export type TestRequest = {
77
78
  // Test requests by URL.
78
79
  export type TestRequests = Record<string, TestRequest[]>;
79
80
 
80
- // Test request addition result.
81
+ // Test request addition result. 'reportExists' means a report already exists for a
82
+ // page a newTest request or order named (an outcome), distinct from the 'retest'
83
+ // requestType discriminant elsewhere in this file, which means the caller asked to
84
+ // retest a page that already has a report (a request kind); the two must not be
85
+ // confused with one another.
81
86
  export type TestRequestResult
82
- = 'url' | 'description' | 'retest' | 'duplicate' | 'superseded' | 'nonreport' | 'ok';
87
+ = 'url' | 'description' | 'reportExists' | 'duplicate' | 'superseded' | 'nonreport' | 'queueFull' | 'ok';
83
88
 
84
89
  // Target of a new-test or retest request: a description and URL for a new-test request,
85
90
  // or the timeStamp and jobID of the cited report for a retest request.
@@ -551,47 +556,41 @@ export const isURL = (string: string): boolean => {
551
556
  return false;
552
557
  }
553
558
  };
554
- // Returns whether an IPv4 address (in dotted-decimal form) is in a private,
555
- // loopback, link-local, or otherwise non-public range, including the
556
- // 169.254.169.254 cloud-metadata address (which falls under link-local).
557
- const isNonPublicIPv4 = (address: string): boolean => {
558
- const octets = address.split('.').map(Number);
559
- const [a, b = 0] = octets;
560
- return (
561
- a === 127 || // loopback
562
- a === 10 || // private
563
- a === 0 || // "this network"
564
- (a === 172 && b >= 16 && b <= 31) || // private
565
- (a === 192 && b === 168) || // private
566
- (a === 169 && b === 254) || // link-local, incl. cloud metadata
567
- (a === 100 && b >= 64 && b <= 127) // carrier-grade NAT
568
- );
569
- };
570
- // Returns whether an IPv6 address is in a loopback, unique-local, or
571
- // link-local range.
572
- const isNonPublicIPv6 = (address: string): boolean => {
573
- const normalized = address.toLowerCase();
574
- return (
575
- normalized === '::1' // loopback
576
- || normalized === '::' // unspecified
577
- || /^::ffff:/.test(normalized) && isNonPublicIPv4(normalized.replace(/^::ffff:/, '')) // IPv4-mapped
578
- || /^f[cd][0-9a-f]{2}:/.test(normalized) // unique local (fc00::/7)
579
- || /^fe[89ab][0-9a-f]:/.test(normalized) // link-local (fe80::/10)
580
- );
581
- };
559
+ // The ipaddr.js range labels, for each address family, that are not part of
560
+ // the public, globally routable address space and so must never be a testing
561
+ // target. Sourced from ipaddr.js's own IPv4/IPv6 SpecialRanges tables, which
562
+ // track the IANA special-purpose address registries (e.g. RFC 1918 private
563
+ // ranges, RFC 5737/3849 documentation ranges, RFC 6598 carrier-grade NAT,
564
+ // RFC 6890 link-local, including the 169.254.169.254 cloud-metadata address).
565
+ // A version bump of ipaddr.js is not pinned or specially reviewed here (this
566
+ // deployment always installs the latest of every dependency); instead, the
567
+ // isAllowedTarget/isAllowedRedirectTarget tests enumerate specific addresses
568
+ // in every one of these ranges, so a future release that narrowed this
569
+ // coverage would fail those tests rather than silently allowing an address
570
+ // that should be rejected.
571
+ const nonPublicIPv4Ranges = new Set([
572
+ 'unspecified', 'broadcast', 'multicast', 'linkLocal', 'loopback', 'carrierGradeNat', 'private', 'reserved'
573
+ ]);
574
+ const nonPublicIPv6Ranges = new Set([
575
+ 'unspecified', 'linkLocal', 'multicast', 'loopback', 'uniqueLocal', 'deprecatedSiteLocal', 'discard',
576
+ 'rfc6145', 'rfc6052', '6to4', 'teredo', 'benchmarking', 'amt', 'as112v6', 'deprecatedOrchid', 'orchid2'
577
+ ]);
582
578
  // Returns whether an IP address (v4 or v6) is outside the public address
583
579
  // space, i.e. is a loopback, private, link-local, or other reserved address
584
580
  // that a public deployment should never intend to test.
585
581
  const isNonPublicIP = (address: string): boolean => {
586
- const version = net.isIP(address);
587
- if (version === 4) {
588
- return isNonPublicIPv4(address);
582
+ let parsed;
583
+ try {
584
+ // process(), rather than parse(), so an IPv4-mapped IPv6 address (e.g.
585
+ // ::ffff:10.0.0.5) is unwrapped and classified by its embedded IPv4 address.
586
+ parsed = ipaddr.process(address);
589
587
  }
590
- if (version === 6) {
591
- return isNonPublicIPv6(address);
588
+ catch {
589
+ // Not a recognizable IP address, so treat it as non-public, i.e. as unsafe.
590
+ return true;
592
591
  }
593
- // Not a recognizable IP address, so treat it as non-public, i.e. as unsafe.
594
- return true;
592
+ const ranges = parsed.kind() === 'ipv4' ? nonPublicIPv4Ranges : nonPublicIPv6Ranges;
593
+ return ranges.has(parsed.range());
595
594
  };
596
595
  // Returns whether ALLOW_INTERNAL_TARGETS opts this deployment in to testing
597
596
  // pages at private, loopback, or link-local addresses. A deployment that
@@ -621,7 +620,9 @@ export const isAllowedTarget = async (url: string, lookup: LookupAll = dns.looku
621
620
  }
622
621
  let hostname: string;
623
622
  try {
624
- hostname = new URL(url).hostname;
623
+ // URL.hostname keeps the surrounding brackets of a literal IPv6 host (e.g.
624
+ // "[::1]"), which net.isIP and dns.lookup both reject, so they are stripped here.
625
+ hostname = new URL(url).hostname.replace(/^\[(.*)\]$/, '$1');
625
626
  } catch {
626
627
  return false;
627
628
  }
@@ -640,6 +641,33 @@ export const isAllowedTarget = async (url: string, lookup: LookupAll = dns.looku
640
641
  return false;
641
642
  }
642
643
  };
644
+ // Returns whether a URL, when actually requested, resolves through any
645
+ // redirects to a final response whose URL is an allowed target. isAllowedTarget
646
+ // only resolves DNS, so it cannot see an application-layer redirect (e.g. a
647
+ // legitimately resolving host issuing a 302 to a private address); this
648
+ // check is what can catch that at request time, before a job is ever queued.
649
+ // It cannot catch a redirect introduced after it runs, e.g. an attacker who
650
+ // reconfigures the target host once a request is accepted, so isAllowedReport
651
+ // remains the backstop that re-checks the URL a worker actually visited.
652
+ export const isAllowedRedirectTarget = async (
653
+ url: string, fetchImpl: typeof fetch = fetch, lookup: LookupAll = dns.lookup
654
+ ): Promise<boolean> => {
655
+ // If this deployment allows internal targets, every syntactically valid URL is
656
+ // allowed, and no fetch is needed, consistent with isAllowedTarget's own early return.
657
+ if (allowsInternalTargets()) {
658
+ return true;
659
+ }
660
+ let response: Response;
661
+ try {
662
+ // redirect: 'follow' is the default, but named here because observing
663
+ // where redirects lead is the entire purpose of this request.
664
+ response = await fetchImpl(url, {redirect: 'follow', signal: AbortSignal.timeout(10000)});
665
+ } catch {
666
+ // An unreachable or errored target cannot be verified as safe either way.
667
+ return false;
668
+ }
669
+ return isAllowedTarget(response.url, lookup);
670
+ };
643
671
  // Returns whether a string is the authorization code, the shared check every
644
672
  // manager-facing page and action uses to gate a submission. Centralized so all call
645
673
  // sites compare against process.env.AUTH_CODE the same way.
@@ -1070,11 +1098,40 @@ const getApprovedJobProperty = async (
1070
1098
  // its addition to testRequests.json are both decided inside the lock, as a single
1071
1099
  // atomic operation, so no caller-visible seam exists where an approvability decision
1072
1100
  // could be based on stale information about testRequests.json.
1101
+ // Returns the non-negative-integer value of an environment variable, or a default if the
1102
+ // variable is unset, empty, or not a non-negative integer. Read at call time, not cached,
1103
+ // so a variable set (e.g. in .env) after this module first loads still takes effect. Used
1104
+ // by the "inbox full" caps below (test requests, tutorial comments, feature requests),
1105
+ // which different Kilotest deployments' maintainers may reasonably want sized differently.
1106
+ // A value of 0 means no limit, mirroring how these caps are checked (never reached).
1107
+ export const getEnvMax = (name: string, defaultValue: number): number => {
1108
+ const raw = process.env[name];
1109
+ if (!raw) {
1110
+ return defaultValue;
1111
+ }
1112
+ const parsed = Number(raw);
1113
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : defaultValue;
1114
+ };
1115
+ // Maximum number of test/retest requests awaiting approval at once, across all URLs.
1116
+ // Once reached, a new, distinct request is rejected rather than added, so the pending
1117
+ // queue and the manager alert emails it generates cannot grow without bound (see GitHub
1118
+ // issue #3, abuse type 3, repeated/automated test-request submissions). Modeled on a full
1119
+ // mailbox rejecting new mail, rather than on a submission rate limit, since it is the
1120
+ // backlog itself, not how quickly it grows, that costs the manager review time and risks
1121
+ // unbounded storage. Configurable via TEST_REQUEST_QUEUE_MAX, since different Kilotest
1122
+ // deployments' maintainers may want a different size; a value of 0 means no limit.
1123
+ const testRequestQueueMax = () => getEnvMax('TEST_REQUEST_QUEUE_MAX', 20);
1124
+ // Returns a cap for display in an alert message: the cap itself, or "no limit" if the
1125
+ // cap is 0.
1126
+ export const describeMax = (max: number): string => max === 0 ? 'no limit' : String(max);
1127
+ // Returns the total number of test/retest requests awaiting approval, across all URLs.
1128
+ const getPendingTestRequestCount = (testRequests: TestRequests): number =>
1129
+ Object.values(testRequests).reduce((total, requests) => total + requests.length, 0);
1073
1130
  export const processTestRequest = (
1074
1131
  reason: string,
1075
1132
  target: TestRequestTarget
1076
1133
  ): Promise<ProcessRequestResult> => testRequestsLock(async (): Promise<ProcessRequestResult> => {
1077
- let requestType: 'test' | 'retest';
1134
+ let requestType: 'newTest' | 'retest';
1078
1135
  let description: string;
1079
1136
  let url: string;
1080
1137
  // Whether the cited report (for a retest) has been superseded by a later one.
@@ -1099,7 +1156,7 @@ export const processTestRequest = (
1099
1156
  }
1100
1157
  // Otherwise, i.e. if the target is a page to test for the first time:
1101
1158
  else {
1102
- requestType = 'test';
1159
+ requestType = 'newTest';
1103
1160
  ({description, url} = target);
1104
1161
  }
1105
1162
  try {
@@ -1133,13 +1190,20 @@ export const processTestRequest = (
1133
1190
  return {result: 'superseded', description, url};
1134
1191
  }
1135
1192
  // Otherwise, if the request is to test a new page for which a report already exists:
1136
- if (requestType === 'test') {
1193
+ if (requestType === 'newTest') {
1137
1194
  const reportExtracts = await getReportExtracts();
1138
1195
  if (reportExtracts.some(report => report.description === description && report.url === url)) {
1139
1196
  // Return this.
1140
- return {result: 'retest', description, url};
1197
+ return {result: 'reportExists', description, url};
1141
1198
  }
1142
1199
  }
1200
+ // Otherwise, i.e. if the request is genuinely new, if the queue of requests awaiting
1201
+ // approval is already full (a cap of 0 means no limit, so the queue is never full):
1202
+ const queueMax = testRequestQueueMax();
1203
+ if (queueMax > 0 && getPendingTestRequestCount(requests) >= queueMax) {
1204
+ // Return this.
1205
+ return {result: 'queueFull', description, url};
1206
+ }
1143
1207
  // The request is eligible, so get the requests awaiting approval.
1144
1208
  const testRequests = await getTestRequests();
1145
1209
  // Add the request to them, initializing them with the URL if necessary.
@@ -1152,10 +1216,14 @@ export const processTestRequest = (
1152
1216
  await fs.writeFile(testRequestsPath(), getJSON(testRequests));
1153
1217
  // Get an email-safe version of the reason.
1154
1218
  const plainReason = getPlainText(reason);
1155
- // Alert a manager.
1219
+ // Alert a manager, including the resulting queue size, so a maintainer who has been
1220
+ // away sees at a glance how urgently the queue needs review (approval or rejection)
1221
+ // rather than learning this only once it is already full.
1222
+ const requestTypeLabel = requestType === 'newTest' ? 'a new test' : 'a retest';
1156
1223
  await sendAlert(
1157
- `Kilotest: new ${requestType} request awaits approval`,
1158
- `Page description: ${description}\nURL: ${url}\nReason: ${plainReason}`
1224
+ `Kilotest: request for ${requestTypeLabel} awaits approval`,
1225
+ `Page description: ${description}\nURL: ${url}\nReason: ${plainReason}\n` +
1226
+ `Requests now awaiting approval: ${getPendingTestRequestCount(testRequests)} of ${describeMax(queueMax)}`
1159
1227
  );
1160
1228
  // Return success.
1161
1229
  return {result: 'ok', description, url};
@@ -1165,6 +1233,169 @@ export const processTestRequest = (
1165
1233
  throw new Error('Failed to process test request', {cause: error});
1166
1234
  }
1167
1235
  });
1236
+ // Maximum number of jobs allowed in the job queue (db/jobs/queue) at once. This queue is
1237
+ // shared by every path that enqueues a job: manual approval of a pending test/retest
1238
+ // request (web/enqueue), and any tool (such as orderNewTest) that enqueues directly. The cap
1239
+ // therefore protects the queue itself, not any one source of jobs; a value of 0 means no
1240
+ // limit. Configurable via JOB_QUEUE_MAX.
1241
+ const jobQueueMax = () => getEnvMax('JOB_QUEUE_MAX', 20);
1242
+ // Result of orderJob. description and url are absent only when the result is
1243
+ // 'nonreport' (a retest order citing a report that does not exist), matching
1244
+ // ProcessRequestResult's own discriminated-union shape. There is no pending approval
1245
+ // inbox to compare against, so 'duplicate' (an identical pending request) never applies.
1246
+ // jobID (the enqueued job's own identifier, timeStamp-jobID) is present only on 'ok'.
1247
+ export type OrderJobResult
1248
+ = {result: 'nonreport'}
1249
+ | {result: 'ok'; description: string; url: string; jobID: string}
1250
+ | {result: Exclude<TestRequestResult, 'nonreport' | 'duplicate' | 'ok'>; description: string; url: string};
1251
+ // Orders a test or retest as a transaction: validates the target is not already queued,
1252
+ // claimed, or (for a new-page order) reported, then enqueues a job directly into
1253
+ // db/jobs/queue, bypassing the pending-approval inbox (testRequests.json) that
1254
+ // processTestRequest writes to. This is the auto-approval GitHub issue #111 asks for: a
1255
+ // lightweight gate (the same claimed/queued dedup check processTestRequest already
1256
+ // performs, plus a cap on the queue itself) substitutes for a maintainer's manual
1257
+ // review. Runs under testRequestsLock, the same lock processTestRequest uses, so an
1258
+ // orderJob call and a processTestRequest call can never race to enqueue duplicate jobs
1259
+ // for the same page. The caller's stated reason is recorded on the job (sources.reason),
1260
+ // which survives into the eventual report exactly as sources.worker already does: no
1261
+ // approval rule consults it yet, but accumulating real callers' stated reasons is
1262
+ // intended to inform the design of more sophisticated, reason-aware approval rules in a
1263
+ // later iteration.
1264
+ export const orderJob = (
1265
+ target: TestRequestTarget, reason: string
1266
+ ): Promise<OrderJobResult> => testRequestsLock(async (): Promise<OrderJobResult> => {
1267
+ let description: string;
1268
+ let url: string;
1269
+ // Whether the cited report (for a retest order) has been superseded by a later one.
1270
+ let superseded = false;
1271
+ // If the target identifies a report to retest:
1272
+ if ('timeStamp' in target) {
1273
+ const {timeStamp, jobID} = target;
1274
+ // Get the cited report.
1275
+ const extract = await getReportExtract(timeStamp, jobID);
1276
+ // If it does not exist:
1277
+ if ('error' in extract) {
1278
+ // Return this.
1279
+ return {result: 'nonreport'};
1280
+ }
1281
+ ({description, url} = extract);
1282
+ // Get whether it has been superseded by a later report.
1283
+ const reportExtracts = await getReportExtracts();
1284
+ superseded = reportExtracts.some(
1285
+ ex => ex.timeStamp === timeStamp && ex.jobID === jobID && ex.superseded
1286
+ );
1287
+ }
1288
+ // Otherwise, i.e. if the target is a page to test for the first time:
1289
+ else {
1290
+ ({description, url} = target);
1291
+ }
1292
+ try {
1293
+ // Get any property the page shares with a claimed job.
1294
+ const claimedJobProperty = await getApprovedJobProperty(description, url, 'claimed');
1295
+ // Get any property the page shares with a queued job.
1296
+ const queueJobProperty = await getApprovedJobProperty(description, url, 'queue');
1297
+ // If the page shares a property with a claimed or queued job:
1298
+ if (claimedJobProperty || queueJobProperty) {
1299
+ // Return the property.
1300
+ return {
1301
+ result: (claimedJobProperty || queueJobProperty) as 'description' | 'url',
1302
+ description,
1303
+ url
1304
+ };
1305
+ }
1306
+ // Otherwise, if the order is to retest a page that has been superseded:
1307
+ if ('timeStamp' in target && superseded) {
1308
+ // Return this.
1309
+ return {result: 'superseded', description, url};
1310
+ }
1311
+ // Otherwise, if the order is to test a new page for which a report already exists:
1312
+ if (!('timeStamp' in target)) {
1313
+ const reportExtracts = await getReportExtracts();
1314
+ if (reportExtracts.some(report => report.description === description && report.url === url)) {
1315
+ // Return this.
1316
+ return {result: 'reportExists', description, url};
1317
+ }
1318
+ }
1319
+ // Otherwise, if the job queue is already full (a cap of 0 means no limit, so the
1320
+ // queue is never full):
1321
+ const queueMax = jobQueueMax();
1322
+ const {queue: queuedJobNames} = await getJobNames();
1323
+ if (queueMax > 0 && queuedJobNames.length >= queueMax) {
1324
+ // Return this.
1325
+ return {result: 'queueFull', description, url};
1326
+ }
1327
+ // The page is eligible, so build a job from the template.
1328
+ const jobTemplateJSON = await fs.readFile(path.join(import.meta.dirname, 'job.json'), 'utf8');
1329
+ const job = JSON.parse(jobTemplateJSON);
1330
+ const nowStamp = getNowStamp();
1331
+ const jobIDSuffix = getRandomString(3);
1332
+ const jobName = `${nowStamp}-${jobIDSuffix}`;
1333
+ job.id = jobName;
1334
+ job.creationTimeStamp = nowStamp;
1335
+ job.executionTimeStamp = nowStamp;
1336
+ job.target.what = description;
1337
+ job.target.url = url;
1338
+ job.sources.reason = reason;
1339
+ // Save the job in the queue.
1340
+ await fs.writeFile(path.join(jobsPath(), 'queue', `${jobName}.json`), getJSON(job));
1341
+ console.log(`Test ordered for ${description} as job ${jobName}`);
1342
+ // Return success, with the job identifier so the caller can report it.
1343
+ return {result: 'ok', description, url, jobID: jobName};
1344
+ // If an error occurred:
1345
+ } catch(error) {
1346
+ // Throw it.
1347
+ throw new Error('Failed to order test', {cause: error});
1348
+ }
1349
+ });
1350
+ // Milliseconds between polls in awaitJob, and the maximum total milliseconds awaitJob
1351
+ // waits before giving up. Both are read at call time (not cached), via environment
1352
+ // variables, so tests can shrink them well below the real ~2-to-4-minute wait a
1353
+ // production caller experiences; AWAIT_JOB_TIMEOUT_MS deliberately exceeds the
1354
+ // documented "occasionally up to 4 minutes" a caller is told to expect, leaving margin
1355
+ // so a job that is genuinely still running is not mistaken for one that never will
1356
+ // finish.
1357
+ const awaitJobPollMs = () => getEnvMax('AWAIT_JOB_POLL_MS', 2000);
1358
+ const awaitJobTimeoutMs = () => getEnvMax('AWAIT_JOB_TIMEOUT_MS', 5 * 60 * 1000);
1359
+ // Result of awaitJob.
1360
+ export type AwaitJobResult = 'completed' | 'failed' | 'timedOut' | 'notFound';
1361
+ // Waits for a job ordered via orderJob (or, in principle, any job in db/jobs/queue or
1362
+ // db/jobs/claimed identified the same way) to leave the queue/claimed pipeline, by
1363
+ // polling for the outcome Kilotest already records for it: a completed report at
1364
+ // timeStamp-jobID.json (see index.ts's worker/report handler, which is the sole writer
1365
+ // of that file), or the job's own file having moved to db/jobs/failed (see
1366
+ // index.ts's processJobRequest and worker/report handler, which are the only
1367
+ // code that reclassifies a job as failed). Returns 'notFound' immediately, without
1368
+ // polling, if the identifier names neither a completed report nor a job anywhere in the
1369
+ // queue/claimed/failed pipeline, since there is then nothing to wait for. No progress
1370
+ // notifications are emitted during the wait; this is a pure bounded poll.
1371
+ export const awaitJob = async (timeStamp: string, jobID: string): Promise<AwaitJobResult> => {
1372
+ const jobFileName = `${timeStamp}-${jobID}.json`;
1373
+ const pollMs = awaitJobPollMs();
1374
+ const timeoutMs = awaitJobTimeoutMs();
1375
+ const deadline = Date.now() + timeoutMs;
1376
+ // Returns the job's outcome if it is now determinable, or null if it is still pending.
1377
+ const checkOutcome = async (): Promise<AwaitJobResult | null> => {
1378
+ if (await getReportStats(timeStamp, jobID)) {
1379
+ return 'completed';
1380
+ }
1381
+ const jobNames = await getJobNames();
1382
+ if (jobNames.failed.includes(jobFileName)) {
1383
+ return 'failed';
1384
+ }
1385
+ if (jobNames.queue.includes(jobFileName) || jobNames.claimed.includes(jobFileName)) {
1386
+ return null;
1387
+ }
1388
+ // The identifier names neither a report nor a job anywhere in the pipeline.
1389
+ return 'notFound';
1390
+ };
1391
+ // If the job is not yet determinable, poll until it is or the deadline passes.
1392
+ let outcome = await checkOutcome();
1393
+ while (outcome === null && Date.now() < deadline) {
1394
+ await new Promise(resolve => setTimeout(resolve, pollMs));
1395
+ outcome = await checkOutcome();
1396
+ }
1397
+ return outcome ?? 'timedOut';
1398
+ };
1168
1399
 
1169
1400
  // METRICS FUNCTIONS
1170
1401
 
@@ -18,7 +18,7 @@
18
18
  __requests__
19
19
  </ol>
20
20
  <p>__noRequests__</p>
21
- <p>Should Kilotest <a href="requestTestForm.html">test a page</a> that is not listed below?</p>
21
+ <p>Should Kilotest <a href="requestNewTestForm.html">test a page</a> that is not listed below?</p>
22
22
  <h2>Queued for testing</h2>
23
23
  <ol>
24
24
  __queue__
@@ -84,7 +84,7 @@ test('listReports includes the page URLs in the report details', async () => {
84
84
  test('listReports includes a link to request testing a new page', async () => {
85
85
  const result: any = await answer();
86
86
  const html = parse(result.answerPage);
87
- const testLink = html.querySelector('a[href="requestTestForm.html"]');
87
+ const testLink = html.querySelector('a[href="requestNewTestForm.html"]');
88
88
  assert.ok(testLink);
89
89
  });
90
90
 
@@ -34,13 +34,22 @@ export const answer = async (description: string, url: string, reason: string) =
34
34
  };
35
35
  }
36
36
  // Otherwise, if a report is available about a page with the same description and URL:
37
- else if (result === 'retest') {
37
+ else if (result === 'reportExists') {
38
38
  // Report this.
39
39
  return {
40
40
  status: 'error',
41
41
  message: 'A report about the page is already available'
42
42
  }
43
43
  }
44
+ // Otherwise, if the queue of requests awaiting approval is full:
45
+ else if (result === 'queueFull') {
46
+ // Report this, with a fallback channel, since this request cannot be queued here.
47
+ return {
48
+ status: 'error',
49
+ message: 'Too many requests are awaiting approval right now. Please try again later, ' +
50
+ 'or post your request at https://github.com/jrpool/kilotest/issues or email info@kilotest.com.'
51
+ }
52
+ }
44
53
  // Otherwise, i.e. if a request to test a page with the same description or URL is approved:
45
54
  else {
46
55
  // Report this.
@@ -1,6 +1,6 @@
1
1
  /*
2
- requestTest.test.ts
3
- Unit tests for web/requestTest/index.ts, covering the success path of answer.
2
+ requestNewTest.test.ts
3
+ Unit tests for web/requestNewTest/index.ts, covering the success path of answer.
4
4
  */
5
5
 
6
6
  // IMPORTS
@@ -128,3 +128,19 @@ test('answer returns an error when a report already exists for the page', {timeo
128
128
  assert.equal(result.status, 'error');
129
129
  assert.equal(result.message, 'A report about the page is already available');
130
130
  });
131
+
132
+ test('answer returns an error with a fallback channel when the request queue is full', {timeout: 500}, async () => {
133
+ const {answer} = await import('./index.ts');
134
+ // Fill testRequests.json with 20 pending requests, all for one URL, to reach the cap.
135
+ const filler = Array.from({length: 20}, (_, i) => ({
136
+ timeStamp: '260101T0000', description: `Filler Page ${i}`, reason: 'Because filler'
137
+ }));
138
+ await fs.writeFile(testRequestsPath, JSON.stringify({'https://example.com/filler': filler}));
139
+ const result: any = await answer(
140
+ 'One Too Many Page', 'https://example.com/one-too-many', 'Because accessibility'
141
+ );
142
+ assert.equal(result.status, 'error');
143
+ assert.ok(result.message.startsWith('Too many requests are awaiting approval right now.'));
144
+ assert.ok(result.message.includes('https://github.com/jrpool/kilotest/issues'));
145
+ assert.ok(result.message.includes('info@kilotest.com'));
146
+ });
@@ -14,7 +14,7 @@
14
14
  <main>
15
15
  <h1><a href="/">Kilotest</a>: Test request</h1>
16
16
  <p>You may request that a web page be tested by submitting the following form. All answers are required.</p>
17
- <form action="/requestTest.html" method="post">
17
+ <form action="/requestNewTest.html" method="post">
18
18
  <p><label>
19
19
  Propose a name for the page.<br>
20
20
  <input size="50" maxlength="70" name="description" required>
@@ -1,6 +1,6 @@
1
1
  /*
2
- requestTestForm.test.ts
3
- Unit tests for web/requestTestForm/index.ts.
2
+ requestNewTestForm.test.ts
3
+ Unit tests for web/requestNewTestForm/index.ts.
4
4
  */
5
5
 
6
6
  // IMPORTS
@@ -12,7 +12,7 @@ import {answer} from './index.ts';
12
12
 
13
13
  // TESTS
14
14
 
15
- test('requestTestForm returns an ok status with valid HTML', async () => {
15
+ test('requestNewTestForm returns an ok status with valid HTML', async () => {
16
16
  const result = await answer();
17
17
  assert.equal(result.status, 'ok');
18
18
  assert.ok(result.answerPage);
@@ -20,16 +20,16 @@ test('requestTestForm returns an ok status with valid HTML', async () => {
20
20
  assert.equal(html.querySelector('title')?.text, 'Test request | Kilotest');
21
21
  });
22
22
 
23
- test('requestTestForm includes a form that posts to /requestTest.html', async () => {
23
+ test('requestNewTestForm includes a form that posts to /requestNewTest.html', async () => {
24
24
  const result = await answer();
25
25
  const html = parse(result.answerPage);
26
26
  const form = html.querySelector('form');
27
27
  assert.ok(form);
28
- assert.equal(form.getAttribute('action'), '/requestTest.html');
28
+ assert.equal(form.getAttribute('action'), '/requestNewTest.html');
29
29
  assert.equal(form.getAttribute('method'), 'post');
30
30
  });
31
31
 
32
- test('requestTestForm includes required inputs for description, url, and why', async () => {
32
+ test('requestNewTestForm includes required inputs for description, url, and why', async () => {
33
33
  const result = await answer();
34
34
  const html = parse(result.answerPage);
35
35
  const inputs = html.querySelectorAll('input');
@@ -51,6 +51,15 @@ export const answer = async (pageArgs: string, reason: string) => {
51
51
  message: 'A later report about the page is already available'
52
52
  }
53
53
  }
54
+ // Otherwise, if the queue of requests awaiting approval is full:
55
+ else if (result === 'queueFull') {
56
+ // Report this, with a fallback channel, since this request cannot be queued here.
57
+ return {
58
+ status: 'error',
59
+ message: 'Too many requests are awaiting approval right now. Please try again later, ' +
60
+ 'or post your request at https://github.com/jrpool/kilotest/issues or email info@kilotest.com.'
61
+ }
62
+ }
54
63
  // Otherwise, i.e. if a request to test a page with the same description or URL is approved:
55
64
  else {
56
65
  // Report this.
@@ -128,3 +128,17 @@ test('answer returns an error when a matching job is already claimed or queued',
128
128
  }
129
129
  });
130
130
 
131
+ test('answer returns an error with a fallback channel when the request queue is full', async () => {
132
+ const {answer} = await import('./index.ts');
133
+ // Fill testRequests.json with 20 pending requests, all for one URL, to reach the cap.
134
+ const filler = Array.from({length: 20}, (_, i) => ({
135
+ timeStamp: '260101T0000', description: `Filler Page ${i}`, reason: 'Because filler'
136
+ }));
137
+ await fs.writeFile(testRequestsPath, JSON.stringify({'https://example.com/filler': filler}));
138
+ const result: any = await answer('260101T0001/ct', 'Because changes were made');
139
+ assert.equal(result.status, 'error');
140
+ assert.ok(result.message.startsWith('Too many requests are awaiting approval right now.'));
141
+ assert.ok(result.message.includes('https://github.com/jrpool/kilotest/issues'));
142
+ assert.ok(result.message.includes('info@kilotest.com'));
143
+ });
144
+
@@ -6,13 +6,20 @@
6
6
  // IMPORTS
7
7
 
8
8
  import {sendAlert} from '../../alerts.ts';
9
- import {checkCommentDuplicate, checkCommentLength, getJSON, getNowStamp} from '../../util.ts';
9
+ import {checkCommentDuplicate, checkCommentLength, describeMax, getEnvMax, getJSON, getNowStamp} from '../../util.ts';
10
10
  import fs from 'node:fs/promises';
11
11
  import path from 'node:path';
12
12
 
13
13
  // FUNCTIONS (helpers)
14
14
 
15
15
  const getCommentsPath = () => process.env.TUTORIAL_AI_COMMENTS_PATH || path.join(import.meta.dirname, '../../db/comments/tutorialAI.json');
16
+ // Maximum number of comments held at once. Once reached, a new, distinct comment is
17
+ // rejected rather than stored, so the comments file and the manager alert emails it
18
+ // generates cannot grow without bound (modeled on a full mailbox rejecting new mail; see
19
+ // GitHub issue #3, abuse type 3). Configurable via TUTORIAL_AI_COMMENTS_MAX, since
20
+ // different Kilotest deployments' maintainers may want a different size; a value of 0
21
+ // means no limit.
22
+ const getCommentsMax = () => getEnvMax('TUTORIAL_AI_COMMENTS_MAX', 20);
16
23
 
17
24
  // FUNCTIONS
18
25
 
@@ -60,6 +67,17 @@ export const handleComment = async (content: unknown) => {
60
67
  if (duplicateCheck.status === 'error') {
61
68
  return duplicateCheck;
62
69
  }
70
+ // If the comments already on file have reached the cap (a cap of 0 means no limit, so
71
+ // the comments are never full):
72
+ const commentsMax = getCommentsMax();
73
+ if (commentsMax > 0 && comments.length >= commentsMax) {
74
+ // Report this, with a fallback channel, since this comment cannot be stored here.
75
+ return {
76
+ status: 'error',
77
+ message: 'Too many comments are awaiting review right now. Please try again later, ' +
78
+ 'or post your comment at https://github.com/jrpool/kilotest/issues or email info@kilotest.com.'
79
+ };
80
+ }
63
81
  // Add the comment to the existing ones.
64
82
  comments.push({
65
83
  timeStamp: getNowStamp(),
@@ -69,7 +87,11 @@ export const handleComment = async (content: unknown) => {
69
87
  await fs.mkdir(path.dirname(commentsPath), {recursive: true});
70
88
  // Save the revised comments.
71
89
  await fs.writeFile(commentsPath, getJSON(comments));
72
- // Send an alert to the manager.
73
- await sendAlert('Kilotest: New AI tutorial comment received', 'A new AI tutorial comment has been received.');
90
+ // Send an alert to the manager, including the resulting count, so a maintainer who has
91
+ // been away sees at a glance how urgently the comments need review.
92
+ await sendAlert(
93
+ 'Kilotest: New AI tutorial comment received',
94
+ `A new AI tutorial comment has been received.\nComments now awaiting review: ${comments.length} of ${describeMax(commentsMax)}`
95
+ );
74
96
  return {status: 'ok'};
75
97
  };