@jrpool/kilotest 46.1.2 → 48.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/.claude/settings.local.json +13 -1
  2. package/.devin/config.local.json +5 -1
  3. package/.github/workflows/ci.yml +0 -3
  4. package/.github/workflows/periodic-smoke-tests.yml +30 -0
  5. package/.github/workflows/publish-mcp.yml +4 -4
  6. package/{alerts.test.js → alerts.test.ts} +17 -29
  7. package/{alerts.js → alerts.ts} +10 -13
  8. package/api/{getReport.test.js → getReport.test.ts} +9 -10
  9. package/api/{getReport.js → getReport.ts} +23 -24
  10. package/api/{listDiagnoses.test.js → listDiagnoses.test.ts} +26 -27
  11. package/api/{listDiagnoses.js → listDiagnoses.ts} +46 -70
  12. package/api/{listIssues.test.js → listIssues.test.ts} +21 -14
  13. package/api/{listIssues.js → listIssues.ts} +75 -74
  14. package/api/{listReports.test.js → listReports.test.ts} +17 -16
  15. package/api/listReports.ts +87 -0
  16. package/api/{listViolators.test.js → listViolators.test.ts} +19 -20
  17. package/api/{listViolators.js → listViolators.ts} +45 -70
  18. package/api/requestFeature.test.ts +54 -0
  19. package/api/{requestFeature.js → requestFeature.ts} +18 -15
  20. package/api/requestRetest.test.ts +144 -0
  21. package/api/requestRetest.ts +119 -0
  22. package/api/requestTest.test.ts +153 -0
  23. package/api/requestTest.ts +123 -0
  24. package/api/{routes.test.js → routes.test.ts} +5 -5
  25. package/api/{routes.js → routes.ts} +6 -6
  26. package/api/{schemas.js → schemas.ts} +69 -50
  27. package/api/{util.test.js → util.test.ts} +35 -14
  28. package/api/{util.js → util.ts} +54 -51
  29. package/api/version.ts +8 -0
  30. package/{apiIntegration.test.js → apiIntegration.test.ts} +12 -13
  31. package/balances.ts +87 -0
  32. package/{checkinfo.js → checkinfo.ts} +24 -20
  33. package/docs/AI-TOOLS.md +4 -4
  34. package/docs/BACKLOG.md +360 -0
  35. package/docs/SERVICE.md +170 -17
  36. package/docs/decisions/002-coverage-tool.md +40 -0
  37. package/docs/decisions/003-adr-threshold.md +34 -0
  38. package/docs/decisions/004-docs-directory.md +23 -0
  39. package/docs/decisions/005-observability.md +28 -0
  40. package/docs/decisions/006-git-workflow.md +28 -0
  41. package/docs/decisions/007-internal-testing.md +34 -0
  42. package/docs/decisions/008-dependencies.md +28 -0
  43. package/docs/decisions/009-qai-integration.md +40 -0
  44. package/docs/decisions/QAI-HISTORICAL-CONTEXT.md +42 -0
  45. package/eslint.config.mjs +27 -0
  46. package/index.html +3 -3
  47. package/{index.test.js → index.test.ts} +397 -152
  48. package/{index.js → index.ts} +386 -285
  49. package/{indexBalances.test.js → indexBalances.test.ts} +21 -17
  50. package/{indexServe.test.js → indexServe.test.ts} +9 -9
  51. package/linkValidation.test.ts +190 -0
  52. package/{mcp.test.js → mcp.test.ts} +53 -26
  53. package/{mcp.js → mcp.ts} +29 -20
  54. package/memory/MEMORY.md +7 -0
  55. package/memory/user_maintainer_context.md +24 -0
  56. package/openapi.yaml +115 -68
  57. package/package.json +35 -15
  58. package/pm2.config.cjs +29 -0
  59. package/{routes.test.js → routes.test.ts} +8 -7
  60. package/{routing.test.js → routing.test.ts} +11 -12
  61. package/scripts/{generate-openapi.js → generate-openapi.ts} +13 -13
  62. package/serve.ts +15 -0
  63. package/smokeTest.ts +234 -0
  64. package/test/dbFixture.ts +21 -0
  65. package/test/fixtures/{buildFixtures.js → buildFixtures.ts} +14 -13
  66. package/test/{setup.cjs → setup.ts} +2 -2
  67. package/tsconfig.json +16 -0
  68. package/util.test.ts +1129 -0
  69. package/util.ts +1017 -0
  70. package/web/ai0BalanceForm/{ai0BalanceForm.test.js → ai0BalanceForm.test.ts} +15 -15
  71. package/web/ai0BalanceForm/{index.js → index.ts} +10 -18
  72. package/web/enqueue/index.ts +56 -0
  73. package/web/enqueueForm/index.html +9 -9
  74. package/web/enqueueForm/index.ts +43 -0
  75. package/web/expungeReportsForm/{expungeReportsForm.test.js → expungeReportsForm.test.ts} +18 -20
  76. package/web/expungeReportsForm/index.ts +18 -0
  77. package/web/hideReportForm/{hideReportForm.test.js → hideReportForm.test.ts} +14 -14
  78. package/web/hideReportForm/{index.js → index.ts} +17 -21
  79. package/web/listDiagnoses/{index.js → index.ts} +48 -58
  80. package/web/listDiagnoses/{listDiagnoses.test.js → listDiagnoses.test.ts} +39 -34
  81. package/web/listIssues/{index.js → index.ts} +67 -92
  82. package/web/listIssues/{listIssues.test.js → listIssues.test.ts} +45 -51
  83. package/web/listReports/index.html +3 -3
  84. package/web/listReports/{index.js → index.ts} +74 -58
  85. package/web/listReports/{listReports.test.js → listReports.test.ts} +41 -43
  86. package/web/listRules/{index.js → index.ts} +18 -23
  87. package/web/listRules/{listRules.test.js → listRules.test.ts} +26 -19
  88. package/web/listTopIssues/{index.js → index.ts} +36 -47
  89. package/web/listTopIssues/{listTopIssues.test.js → listTopIssues.test.ts} +26 -17
  90. package/web/listViolators/{index.js → index.ts} +51 -71
  91. package/web/listViolators/{listViolators.test.js → listViolators.test.ts} +43 -36
  92. package/web/manage/index.ts +22 -0
  93. package/web/pruneReportsForm/index.ts +18 -0
  94. package/web/pruneReportsForm/{pruneReportsForm.test.js → pruneReportsForm.test.ts} +11 -11
  95. package/web/reannotate/index.ts +71 -0
  96. package/web/reannotate/{reannotate.test.js → reannotate.test.ts} +19 -22
  97. package/web/reannotateForm/{index.js → index.ts} +41 -50
  98. package/web/reannotateForm/{reannotateForm.test.js → reannotateForm.test.ts} +13 -16
  99. package/web/renewWCAG/{index.js → index.ts} +9 -9
  100. package/web/renewWCAG/{renewWCAG.test.js → renewWCAG.test.ts} +17 -20
  101. package/web/renewWCAGForm/index.ts +22 -0
  102. package/web/{expungeReportsForm/index.js → reportDeletion.ts} +57 -39
  103. package/web/requestRetest/index.html +7 -7
  104. package/web/requestRetest/index.ts +62 -0
  105. package/web/requestRetest/requestRetest.test.ts +130 -0
  106. package/web/requestRetestForm/{index.js → index.ts} +10 -16
  107. package/web/requestRetestForm/{requestRetestForm.test.js → requestRetestForm.test.ts} +11 -12
  108. package/web/requestTest/index.html +7 -7
  109. package/web/requestTest/index.ts +52 -0
  110. package/web/requestTest/{requestTest.test.js → requestTest.test.ts} +39 -24
  111. package/web/requestTestForm/index.html +4 -4
  112. package/web/requestTestForm/index.ts +21 -0
  113. package/web/requestTestForm/{requestTestForm.test.js → requestTestForm.test.ts} +9 -9
  114. package/web/rewindReportsForm/index.ts +18 -0
  115. package/web/rewindReportsForm/{rewindReportsForm.test.js → rewindReportsForm.test.ts} +11 -11
  116. package/web/tutorialAI/index.html +184 -0
  117. package/web/tutorialAI/index.ts +80 -0
  118. package/web/tutorialAI/tutorialAI.test.ts +192 -0
  119. package/web/{tutorial → tutorialWeb}/index.html +9 -8
  120. package/web/tutorialWeb/index.ts +78 -0
  121. package/web/tutorialWeb/tutorialWeb.test.ts +195 -0
  122. package/web/unhideReportForm/{index.js → index.ts} +18 -22
  123. package/web/unhideReportForm/{unhideReportForm.test.js → unhideReportForm.test.ts} +16 -16
  124. package/aceconfig.js +0 -28
  125. package/api/listReports.js +0 -89
  126. package/api/requestFeature.test.js +0 -58
  127. package/api/requestRetest.js +0 -96
  128. package/api/requestRetest.test.js +0 -80
  129. package/api/requestTest.js +0 -106
  130. package/api/requestTest.test.js +0 -92
  131. package/api/version.js +0 -8
  132. package/indexFallback.test.js +0 -33
  133. package/indexWorkers.test.js +0 -79
  134. package/jsconfig.json +0 -8
  135. package/pm2.config.js +0 -18
  136. package/smokeTest.js +0 -128
  137. package/util.js +0 -744
  138. package/util.test.js +0 -968
  139. package/web/enqueue/index.js +0 -67
  140. package/web/enqueueForm/index.js +0 -49
  141. package/web/manage/index.js +0 -22
  142. package/web/pruneReportsForm/index.js +0 -123
  143. package/web/reannotate/index.js +0 -58
  144. package/web/renewWCAGForm/index.js +0 -22
  145. package/web/requestRetest/index.js +0 -31
  146. package/web/requestRetest/requestRetest.test.js +0 -119
  147. package/web/requestTest/index.js +0 -24
  148. package/web/requestTestForm/index.js +0 -21
  149. package/web/rewindReportsForm/index.js +0 -121
  150. package/web/tutorial/index.js +0 -63
  151. package/web/tutorial/tutorial.test.js +0 -97
  152. /package/test/fixtures/db/jobs/{recs.json → testRequests.json} +0 -0
  153. /package/web/{tutorial → tutorialWeb}/images/newsletter-form.png +0 -0
@@ -1,18 +1,18 @@
1
1
  /*
2
- index.js
2
+ index.ts
3
3
  Renews the WCAG map.
4
4
  */
5
5
 
6
6
  // IMPORTS
7
7
 
8
- const fs = require('fs/promises');
9
- const path = require('path');
10
- const {getJSON} = require('../../util');
8
+ import fs from 'node:fs/promises';
9
+ import path from 'node:path';
10
+ import {getJSON} from '../../util.ts';
11
11
 
12
12
  // FUNCTIONS
13
13
 
14
14
  // Renews the WCAG map and serves an acknowledgment.
15
- exports.answer = async authCode => {
15
+ export const answer = async (authCode: string) => {
16
16
  // If the authorization code is valid:
17
17
  if (authCode === process.env.AUTH_CODE) {
18
18
  // Get the map source response.
@@ -29,16 +29,16 @@ exports.answer = async authCode => {
29
29
  // If there are entries:
30
30
  if (mapEntries) {
31
31
  // Initialize the WCAG map.
32
- const wcagMap = {};
32
+ const wcagMap: Record<string, string> = {};
33
33
  // For each entry:
34
34
  for (const entry of mapEntries) {
35
35
  // Add it to the map.
36
- wcagMap[entry[2]] = entry[1];
36
+ wcagMap[entry[2]!] = entry[1]!;
37
37
  }
38
38
  // Save the map, replacing any existing one.
39
- await fs.writeFile(path.join(__dirname, '..', '..', 'wcagMap.json'), getJSON(wcagMap));
39
+ await fs.writeFile(path.join(import.meta.dirname, '..', '..', 'wcagMap.json'), getJSON(wcagMap));
40
40
  // Get the acknowledgment page.
41
- const answerPage = await fs.readFile(path.join(__dirname, 'index.html'), 'utf8');
41
+ const answerPage = await fs.readFile(path.join(import.meta.dirname, 'index.html'), 'utf8');
42
42
  // Return it.
43
43
  return {
44
44
  status: 'ok',
@@ -1,20 +1,20 @@
1
1
  /*
2
- renewWCAG.test.js
3
- Unit tests for web/renewWCAG/index.js, covering auth, fetch status, and success branches.
2
+ renewWCAG.test.ts
3
+ Unit tests for web/renewWCAG/index.ts, covering auth, fetch status, and success branches.
4
4
  */
5
5
 
6
6
  // IMPORTS
7
7
 
8
- const {test, before, after, afterEach} = require('node:test');
9
- const assert = require('node:assert/strict');
10
- const fs = require('node:fs/promises');
11
- const path = require('node:path');
12
- const {parse} = require('node-html-parser');
8
+ import {test, before, after, afterEach} from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import fs from 'node:fs/promises';
11
+ import path from 'node:path';
12
+ import {parse} from 'node-html-parser';
13
13
 
14
14
  // CONSTANTS
15
15
 
16
- const fixtureDBDir = path.join(__dirname, '..', '..', 'test', 'fixtures', 'db');
17
- const wcagMapPath = path.join(__dirname, '..', '..', 'wcagMap.json');
16
+ import {fixtureDBDir} from '../../test/dbFixture.ts';
17
+ const wcagMapPath = path.join(import.meta.dirname, '..', '..', 'wcagMap.json');
18
18
 
19
19
  // SETUP AND TEARDOWN
20
20
 
@@ -23,8 +23,8 @@ before(async () => {
23
23
  process.env.AUTH_CODE = 'test-auth-code';
24
24
  });
25
25
 
26
- let originalFetch;
27
- let wcagMapBackup;
26
+ let originalFetch: any;
27
+ let wcagMapBackup: any;
28
28
 
29
29
  afterEach(async () => {
30
30
  // Restore the original fetch after each test.
@@ -50,7 +50,7 @@ after(async () => {
50
50
  // HELPER
51
51
 
52
52
  // Replaces global.fetch with a mock that returns the given status and body.
53
- const mockFetch = (status, body = '') => {
53
+ const mockFetch = (status: number, body = '') => {
54
54
  originalFetch = global.fetch;
55
55
  // @ts-expect-error: Replacing the real function with a mock for testing.
56
56
  global.fetch = async () => ({
@@ -62,7 +62,7 @@ const mockFetch = (status, body = '') => {
62
62
  // TESTS
63
63
 
64
64
  test('answer returns an error for an invalid authorization code', async () => {
65
- const {answer} = require('./index');
65
+ const {answer} = await import('./index.ts');
66
66
  const result = await answer('wrong-code');
67
67
  assert.equal(result.status, 'error');
68
68
  assert.equal(result.message, 'Authorization code invalid');
@@ -70,8 +70,7 @@ test('answer returns an error for an invalid authorization code', async () => {
70
70
 
71
71
  test('answer returns an error when the WCAG map source returns a non-200 status', async () => {
72
72
  mockFetch(404);
73
- delete require.cache[require.resolve('./index')];
74
- const {answer} = require('./index');
73
+ const {answer} = await import('./index.ts');
75
74
  const result = await answer('test-auth-code');
76
75
  assert.equal(result.status, 'error');
77
76
  assert.equal(result.message, 'WCAG map source not retrieved');
@@ -87,8 +86,7 @@ test('answer returns ok and rewrites the WCAG map when the source returns valid
87
86
  '<a href="understanding/target-size-minimum"><span class="secno">2.5.8 </span>'
88
87
  ].join('\n');
89
88
  mockFetch(200, mockHTML);
90
- delete require.cache[require.resolve('./index')];
91
- const {answer} = require('./index');
89
+ const {answer} = await import('./index.ts');
92
90
  const result = await answer('test-auth-code');
93
91
  assert.equal(result.status, 'ok');
94
92
  assert.ok(result.answerPage, 'answerPage should be present');
@@ -107,13 +105,12 @@ test('answer returns ok and rewrites the WCAG map when the source returns valid
107
105
  test('answer returns an error when matchAll returns no entries', async () => {
108
106
  // Mock matchAll to return null, covering the dead else branch on lines 48-55.
109
107
  const originalMatchAll = String.prototype.matchAll;
110
- String.prototype.matchAll = function() {
108
+ String.prototype.matchAll = function(): any {
111
109
  return null;
112
110
  };
113
111
  mockFetch(200, '<html></html>');
114
112
  try {
115
- delete require.cache[require.resolve('./index')];
116
- const {answer} = require('./index');
113
+ const {answer} = await import('./index.ts');
117
114
  const result = await answer('test-auth-code');
118
115
  assert.equal(result.status, 'error');
119
116
  assert.equal(result.message, 'No entries found in WCAG map source');
@@ -0,0 +1,22 @@
1
+ /*
2
+ index.ts
3
+ Serves a form for renewing the WCAG map.
4
+ */
5
+
6
+ // IMPORTS
7
+
8
+ import fs from 'node:fs/promises';
9
+ import path from 'node:path';
10
+
11
+ // FUNCTIONS
12
+
13
+ // Returns a form for renewing the WCAG map.
14
+ export const answer = async (): Promise<{status: string; answerPage: string}> => {
15
+ // Get the map renewal form.
16
+ const answerPage = await fs.readFile(path.join(import.meta.dirname, 'index.html'), 'utf8');
17
+ // Return it.
18
+ return {
19
+ status: 'ok',
20
+ answerPage
21
+ };
22
+ };
@@ -1,18 +1,44 @@
1
1
  /*
2
- index.js
3
- Serves a form for deleting sole reports.
2
+ reportDeletion.ts
3
+ Shared implementation of the forms for deleting reports.
4
4
  */
5
5
 
6
6
  // IMPORTS
7
7
 
8
- const {getReportData, objectSort, reportsPath} = require('../../util');
9
- const fs = require('fs/promises');
10
- const path = require('path');
8
+ import {
9
+ errorMessage, getReportData, objectSort, populateTemplate, readdirOrCreate, reportsPath
10
+ } from '../util.ts';
11
+ import fs from 'node:fs/promises';
12
+ import path from 'node:path';
13
+
14
+ // TYPES
15
+
16
+ // A summary of an available report, as shown in a deletion form.
17
+ type ReportSpec = {
18
+ timeStamp: string;
19
+ jobID: string;
20
+ issueCount: number;
21
+ preventedEngineCount: number;
22
+ url: string;
23
+ };
24
+
25
+ // The distinctive properties of a report-deletion form.
26
+ export type DeletionSpec = {
27
+ intro: string;
28
+ emptyIntro: string;
29
+ failurePrefix: string;
30
+ isDeletable: (specs: ReportSpec[], index: number) => boolean;
31
+ };
11
32
 
12
33
  // FUNCTIONS
13
34
 
14
- // Returns a form for deleting sole reports.
15
- exports.answer = async (_, search) => {
35
+ // Returns a form for deleting the reports that the predicate marks as deletable.
36
+ export const reportDeletionForm = async (
37
+ dirName: string,
38
+ search: string,
39
+ deletionSpec: DeletionSpec
40
+ ): Promise<{status: string; message?: string; answerPage?: string}> => {
41
+ const {emptyIntro, failurePrefix, intro, isDeletable} = deletionSpec;
16
42
  const searchParams = new URLSearchParams(search);
17
43
  const authCode = searchParams?.get('authCode');
18
44
  const jobNames = searchParams?.getAll('report');
@@ -28,12 +54,12 @@ exports.answer = async (_, search) => {
28
54
  }
29
55
  }
30
56
  // If this failed:
31
- catch (error) {
57
+ catch (error: unknown) {
32
58
  // Return why.
33
59
  return {
34
60
  status: 'error',
35
- message: `Deleting sole reports failed (${error.message})`
36
- }
61
+ message: `${failurePrefix} (${errorMessage(error)})`
62
+ };
37
63
  }
38
64
  }
39
65
  // Otherwise, i.e. if the authorization code is invalid:
@@ -42,25 +68,27 @@ exports.answer = async (_, search) => {
42
68
  return {
43
69
  status: 'error',
44
70
  message: 'Invalid authorization code'
45
- }
71
+ };
46
72
  }
47
73
  }
48
- const reportNames = await fs.readdir(reportsPath());
49
- const reportSpecs = [];
74
+ const reportNames = await readdirOrCreate(reportsPath(), 'Reports directory');
75
+ // Initialize an array of report summaries.
76
+ const reportSpecs: ReportSpec[] = [];
50
77
  // For each report:
51
78
  for (const reportName of reportNames) {
52
- const [timeStamp, jobID] = reportName.slice(0, -5).split('-');
79
+ const [timeStamp, jobID] = reportName.slice(0, -5).split('-') as [string, string];
53
80
  // Get a summary of it.
54
81
  const reportFacts = await getReportData(timeStamp, jobID);
55
- const {error, issueCount, preventedEngineCount, url} = reportFacts;
56
82
  // If this failed:
57
- if (error) {
83
+ if (reportFacts.error !== undefined) {
58
84
  // Return why.
59
85
  return {
60
86
  status: 'error',
61
- message: error
62
- }
87
+ message: reportFacts.error
88
+ };
63
89
  }
90
+ const {issueCount, preventedEngineCount, url} = reportFacts;
91
+ // Otherwise, i.e. if it succeeded, add the summary to the array.
64
92
  reportSpecs.push({
65
93
  timeStamp,
66
94
  jobID,
@@ -69,49 +97,39 @@ exports.answer = async (_, search) => {
69
97
  url
70
98
  });
71
99
  }
72
- // Sort the summaries primarily URL and secondarily by time stamp.
100
+ // Sort the summaries primarily by URL and secondarily by time stamp.
73
101
  objectSort(reportSpecs, 'timeStamp', 'alpha');
74
102
  objectSort(reportSpecs, 'url', 'alpha');
75
- const lines = [];
103
+ const lines: string[] = [];
76
104
  const margin = ' '.repeat(12);
77
105
  let anyDeletable = false;
78
106
  // For each summary:
79
- reportSpecs.forEach((spec, index) => {
80
- const {timeStamp, jobID, issueCount, preventedEngineCount, url} = spec;
107
+ reportSpecs.forEach((reportSpec, index) => {
108
+ const {timeStamp, jobID, issueCount, preventedEngineCount, url} = reportSpec;
81
109
  const jobName = `${timeStamp}-${jobID}`;
82
110
  const specString = `<code>${url}</code> (<code>${jobName}</code>): preventions ${preventedEngineCount}, issues ${issueCount}`;
83
- // If its report is the sole report on a target:
84
- if (reportSpecs[index - 1]?.url !== url && reportSpecs[index + 1]?.url !== url) {
111
+ // If its report is deletable:
112
+ if (isDeletable(reportSpecs, index)) {
85
113
  // Add a line with a deletion checkbox.
86
114
  lines.push(
87
115
  `${margin}<p><input type="checkbox" name="report" value="${jobName}"> ${specString}</p>`
88
116
  );
89
117
  anyDeletable = true;
90
118
  }
91
- // Otherwise, i.e. if its report is not the sole report on a target:
119
+ // Otherwise, i.e. if its report is not deletable:
92
120
  else {
93
121
  // Add a line without a deletion checkbox.
94
122
  lines.push(`${margin}<p>${specString}</p>`);
95
123
  }
96
124
  });
97
- const intro = anyDeletable
98
- ? 'Choose the sole reports to delete.'
99
- : 'Each target has at least 2 reports, so there are no reports to delete.';
100
- const disabled = anyDeletable ? '' : ' disabled';
101
- const query = {
125
+ const query: Record<string, string> = {
102
126
  reports: lines.join('\n'),
103
- intro,
104
- disabled
127
+ intro: anyDeletable ? intro : emptyIntro,
128
+ disabled: anyDeletable ? '' : ' disabled'
105
129
  };
106
- // Get the order form template.
107
- let answerPage = await fs.readFile(path.join(__dirname, 'index.html'), 'utf8');
108
- // Replace its placeholders.
109
- Object.keys(query).forEach(param => {
110
- answerPage = answerPage.replace(new RegExp(`__${param}__`, 'g'), query[param]);
111
- });
112
130
  // Return the populated page.
113
131
  return {
114
132
  status: 'ok',
115
- answerPage
133
+ answerPage: await populateTemplate(dirName, query)
116
134
  };
117
135
  };
@@ -6,18 +6,18 @@
6
6
  <meta name="publisher" content="Jonathan Robert Pool">
7
7
  <meta name="creator" content="Jonathan Robert Pool">
8
8
  <meta name="keywords" content="report,accessibility,a11y">
9
- <title>__target__ retest recommendation received | Kilotest</title>
9
+ <title>__target__ retest request received | Kilotest</title>
10
10
  <link rel="icon" href="/favicon.ico">
11
11
  <link rel="stylesheet" href="/style.css">
12
12
  </head>
13
13
  <body>
14
14
  <main>
15
- <h1><a href="/">Kilotest</a>: Retest recommendation received for __target__</h1>
16
- <p>Your recommendation to retest the __target__ page has been received.</p>
17
- <p>The reason for your recommendation has been recorded as <q>__why__</q>.</p>
18
- <p>Thank you for your recommendation.</p>
19
- <p>If a <a href="/enqueueForm.html">Kilotest manager approves this recommendation</a>, a test order will be added to the queue.</p>
20
- <p>In most cases, if you check Kilotest again in 1 day, you will find that the recommended test has been performed.</p>
15
+ <h1><a href="/">Kilotest</a>: Retest request received for __description__</h1>
16
+ <p>Your request to retest the __description__ page at __url__ has been received.</p>
17
+ <p>The reason for your request has been recorded as <q>__reason__</q>.</p>
18
+ <p>Thank you for your request.</p>
19
+ <p>If a <a href="/enqueueForm.html">Kilotest manager approves this request</a>, a test order will be added to the queue.</p>
20
+ <p>In most cases, if you check Kilotest again in 1 day, you will find that the requested test has been performed.</p>
21
21
  </main>
22
22
  </body>
23
23
  </html>
@@ -0,0 +1,62 @@
1
+ /*
2
+ index.ts
3
+ Records a retest request.
4
+ */
5
+
6
+ // IMPORTS
7
+
8
+ import {populateTemplate, processTestRequest} from '../../util.ts';
9
+
10
+ // FUNCTIONS
11
+
12
+ export const answer = async (pageArgs: string, reason: string) => {
13
+ const [timeStamp, jobID] = pageArgs.split('/') as [string, string];
14
+ // Process the request. This resolves the cited report's description and URL itself.
15
+ const processResult = await processTestRequest(reason, {timeStamp, jobID});
16
+ // If the cited report does not exist:
17
+ if (processResult.result === 'nonreport') {
18
+ // Report this.
19
+ return {
20
+ status: 'error',
21
+ message: 'Invalid request'
22
+ };
23
+ }
24
+ const {result, description, url} = processResult;
25
+ // If the request was recorded:
26
+ if (result === 'ok') {
27
+ const query = {
28
+ description,
29
+ url,
30
+ reason
31
+ };
32
+ // Return the populated page.
33
+ return {
34
+ status: 'ok',
35
+ answerPage: await populateTemplate(import.meta.dirname, query)
36
+ };
37
+ }
38
+ // Otherwise, if the request is a duplicate:
39
+ else if (result === 'duplicate') {
40
+ // Report this.
41
+ return {
42
+ status: 'error',
43
+ message: 'Test request duplicates an already submitted request'
44
+ };
45
+ }
46
+ // Otherwise, if the cited report has been superseded:
47
+ else if (result === 'superseded') {
48
+ // Report this.
49
+ return {
50
+ status: 'error',
51
+ message: 'A later report about the page is already available'
52
+ }
53
+ }
54
+ // Otherwise, i.e. if a request to test a page with the same description or URL is approved:
55
+ else {
56
+ // Report this.
57
+ return {
58
+ status: 'error',
59
+ message: 'A request to test a page with the same description or URL is already approved'
60
+ }
61
+ }
62
+ };
@@ -0,0 +1,130 @@
1
+ /*
2
+ requestRetest.test.ts
3
+ Unit tests for web/requestRetest/index.ts, covering the success path of answer.
4
+ */
5
+
6
+ // IMPORTS
7
+
8
+ import {test, before, after, beforeEach} from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import fs from 'node:fs/promises';
11
+ import path from 'node:path';
12
+ import {parse} from 'node-html-parser';
13
+
14
+ // CONSTANTS
15
+
16
+ import {fixtureDBDir} from '../../test/dbFixture.ts';
17
+ const testRequestsPath = path.join(fixtureDBDir, 'jobs', 'testRequests.json');
18
+
19
+ // SETUP AND TEARDOWN
20
+
21
+ before(async () => {
22
+ process.env.DB_DIR = fixtureDBDir;
23
+ process.env.AUTH_CODE = 'test-auth-code';
24
+ });
25
+
26
+ beforeEach(async () => {
27
+ // Reset testRequests.json and clean job directories before each test.
28
+ await fs.writeFile(testRequestsPath, '{}\n');
29
+ for (const sub of ['claimed', 'queue', 'failed']) {
30
+ const dir = path.join(fixtureDBDir, 'jobs', sub);
31
+ await fs.mkdir(dir, {recursive: true});
32
+ const files = await fs.readdir(dir).catch(() => []);
33
+ for (const file of files) {
34
+ try {
35
+ await fs.unlink(path.join(dir, file));
36
+ }
37
+ catch {
38
+ // Ignore cleanup errors for files that may not exist.
39
+ }
40
+ }
41
+ }
42
+ });
43
+
44
+ after(async () => {
45
+ // Restore testRequests.json and clean job directories after all tests.
46
+ await fs.writeFile(testRequestsPath, '{}\n');
47
+ for (const sub of ['claimed', 'queue', 'failed']) {
48
+ const dir = path.join(fixtureDBDir, 'jobs', sub);
49
+ await fs.mkdir(dir, {recursive: true});
50
+ const files = await fs.readdir(dir).catch(() => []);
51
+ for (const file of files) {
52
+ try {
53
+ await fs.unlink(path.join(dir, file));
54
+ }
55
+ catch {
56
+ // Ignore cleanup errors for files that may not exist.
57
+ }
58
+ }
59
+ }
60
+ });
61
+
62
+ // TESTS
63
+
64
+ test('answer returns ok with a populated answer page for a valid retest request', async () => {
65
+ // Require the module after DB_DIR is set.
66
+ const {answer} = await import('./index.ts');
67
+ // Use the 260101T0001-ct report (All CantTell Page, https://example.com/canttell).
68
+ const result: any = await answer('260101T0001/ct', 'Because changes were made');
69
+ assert.equal(result.status, 'ok');
70
+ assert.ok(result.answerPage, 'answerPage should be present');
71
+ // Verify the description and reason placeholders were replaced with the report target.
72
+ // (Note: the template's <title> uses __target__, a placeholder query never supplies;
73
+ // that pre-existing template mismatch is out of scope here and left unasserted.)
74
+ assert.ok(result.answerPage.includes('All CantTell Page'));
75
+ assert.ok(result.answerPage.includes('Because changes were made'));
76
+ assert.ok(!result.answerPage.includes('__description__'));
77
+ assert.ok(!result.answerPage.includes('__url__'));
78
+ assert.ok(!result.answerPage.includes('__reason__'));
79
+ // Verify the HTML is well-formed.
80
+ const doc = parse(result.answerPage);
81
+ const h1 = doc.querySelector('h1');
82
+ assert.ok(h1);
83
+ assert.ok(h1.textContent.includes('All CantTell Page'));
84
+ });
85
+
86
+ test('answer returns an error when the report does not exist', async () => {
87
+ const {answer} = await import('./index.ts');
88
+ // Use a nonexistent report timestamp and jobID.
89
+ const result: any = await answer('990101T0000/xxx', 'Because changes were made');
90
+ assert.equal(result.status, 'error');
91
+ assert.equal(result.message, 'Invalid request');
92
+ });
93
+
94
+ test('answer returns an error for a duplicate retest request', async () => {
95
+ const {answer} = await import('./index.ts');
96
+ // Submit the same retest request twice; the second is a duplicate.
97
+ await answer('260101T0001/ct', 'Because changes were made');
98
+ const result: any = await answer('260101T0001/ct', 'Because changes were made');
99
+ assert.equal(result.status, 'error');
100
+ assert.equal(result.message, 'Test request duplicates an already submitted request');
101
+ });
102
+
103
+ test('answer returns an error when the cited report has been superseded', async () => {
104
+ const {answer} = await import('./index.ts');
105
+ // 260101T0000-mix (Mixed Outcomes Page) is superseded by 260202T0000-new.
106
+ const result: any = await answer('260101T0000/mix', 'Because changes were made');
107
+ assert.equal(result.status, 'error');
108
+ assert.equal(result.message, 'A later report about the page is already available');
109
+ });
110
+
111
+ test('answer returns an error when a matching job is already claimed or queued', async () => {
112
+ const {answer} = await import('./index.ts');
113
+ // Create a claimed job sharing the description of the cited report
114
+ // (260101T0001-ct, "All CantTell Page").
115
+ const claimedDir = path.join(fixtureDBDir, 'jobs', 'claimed');
116
+ const jobPath = path.join(claimedDir, 'claimedJob.json');
117
+ await fs.writeFile(jobPath, JSON.stringify({
118
+ id: 'claimedJob',
119
+ target: {what: 'All CantTell Page', url: 'https://example.com/unrelated'}
120
+ }));
121
+ try {
122
+ const result: any = await answer('260101T0001/ct', 'Because changes were made');
123
+ assert.equal(result.status, 'error');
124
+ assert.equal(result.message, 'A request to test a page with the same description or URL is already approved');
125
+ }
126
+ finally {
127
+ await fs.unlink(jobPath).catch(() => {});
128
+ }
129
+ });
130
+
@@ -1,38 +1,36 @@
1
1
  /*
2
- index.js
2
+ index.ts
3
3
  Serves a form for requesting a retest.
4
4
  */
5
5
 
6
6
  // IMPORTS
7
7
 
8
- const {getAgoString, getDateTimeString, getLatestReportExtracts} = require('../../util');
9
- const fs = require('fs/promises');
10
- const path = require('path');
8
+ import {getAgoString, getDateTimeString, getReportExtracts, populateTemplate} from '../../util.ts';
11
9
 
12
10
  // FUNCTIONS
13
11
 
14
12
  // Returns a retest recommendation form.
15
- exports.answer = async pageArgs => {
16
- const [timeStamp, jobID] = pageArgs.split('/');
13
+ export const answer = async (pageArgs: string) => {
14
+ const [timeStamp, jobID] = pageArgs.split('/') as [string, string];
17
15
  // Get data on the latest available reports.
18
- const reportExtracts = await getLatestReportExtracts();
16
+ const reportExtracts = await getReportExtracts(true);
19
17
  // Get data on the report whose page is to be retested.
20
18
  const reportExtract = reportExtracts.find(
21
- reportExtract => reportExtract.timeStamp === timeStamp && reportExtract.jobID === jobID
19
+ (reportExtract: any) => reportExtract.timeStamp === timeStamp && reportExtract.jobID === jobID
22
20
  );
23
21
  // Initialize the page description.
24
- let target;
22
+ let target: string;
25
23
  // If getting the data succeeded:
26
24
  if (reportExtract) {
27
25
  // Update the page description.
28
- target = reportExtract.what;
26
+ target = reportExtract.description;
29
27
  }
30
28
  // Otherwise, i.e. if it failed:
31
29
  else {
32
30
  // Make the form report the failure.
33
31
  target = 'The specified page is not available for retesting';
34
32
  }
35
- const query = {
33
+ const query: Record<string, string> = {
36
34
  target,
37
35
  timeStamp,
38
36
  jobID,
@@ -40,11 +38,7 @@ exports.answer = async pageArgs => {
40
38
  dateTime: getDateTimeString(timeStamp)
41
39
  };
42
40
  // Get the recommendation form template.
43
- let answerPage = await fs.readFile(path.join(__dirname, 'index.html'), 'utf8');
44
- // Replace its placeholders.
45
- Object.keys(query).forEach(param => {
46
- answerPage = answerPage.replace(new RegExp(`__${param}__`, 'g'), query[param]);
47
- });
41
+ const answerPage = await populateTemplate(import.meta.dirname, query);
48
42
  // Return the populated page.
49
43
  return {
50
44
  status: 'ok',
@@ -1,22 +1,21 @@
1
1
  /*
2
- requestRetestForm.test.js
3
- Unit tests for web/requestRetestForm/index.js.
2
+ requestRetestForm.test.ts
3
+ Unit tests for web/requestRetestForm/index.ts.
4
4
  */
5
5
 
6
6
  // IMPORTS
7
7
 
8
- const {test, before, after} = require('node:test');
9
- const assert = require('node:assert/strict');
10
- const path = require('node:path');
11
- const {parse} = require('node-html-parser');
12
- const {answer} = require('./index');
8
+ import {test, before, after} from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import {parse} from 'node-html-parser';
11
+ import {answer} from './index.ts';
13
12
 
14
13
  // SETUP AND TEARDOWN
15
14
 
16
15
  const savedDBDir = process.env.DB_DIR;
17
16
 
18
- before(() => {
19
- process.env.DB_DIR = path.join(__dirname, '..', '..', 'test', 'fixtures', 'db');
17
+ before(async () => {
18
+ process.env.DB_DIR = (await import('../../test/dbFixture.ts')).fixtureDBDir;
20
19
  });
21
20
 
22
21
  after(() => {
@@ -35,15 +34,15 @@ test('requestRetestForm returns ok with the page name for a valid report', async
35
34
  assert.equal(result.status, 'ok');
36
35
  assert.ok(result.answerPage);
37
36
  const html = parse(result.answerPage);
38
- const title = html.querySelector('title').text;
39
- assert.ok(title.includes('Mixed Outcomes Page'));
37
+ const title = html.querySelector('title')?.text;
38
+ assert.ok(title?.includes('Mixed Outcomes Page'));
40
39
  });
41
40
 
42
41
  test('requestRetestForm populates the form action with the timeStamp and jobID', async () => {
43
42
  const result = await answer('260202T0000/new');
44
43
  const html = parse(result.answerPage);
45
44
  const form = html.querySelector('form');
46
- assert.equal(form.getAttribute('action'), '/requestRetest.html/260202T0000/new');
45
+ assert.equal(form?.getAttribute('action'), '/requestRetest.html/260202T0000/new');
47
46
  });
48
47
 
49
48
  test('requestRetestForm shows the fallback message for a nonexistent report', async () => {