@jrpool/kilotest 47.0.1 → 49.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 (63) hide show
  1. package/.claude/scheduled_tasks.lock +1 -0
  2. package/api/requestFeature.test.ts +4 -2
  3. package/api/requestFeature.ts +1 -1
  4. package/api/requestRetest.test.ts +66 -7
  5. package/api/requestRetest.ts +55 -39
  6. package/api/requestTest.test.ts +63 -7
  7. package/api/requestTest.ts +36 -26
  8. package/api/util.test.ts +6 -5
  9. package/api/util.ts +2 -24
  10. package/docs/BACKLOG.md +228 -16
  11. package/docs/SERVICE.md +84 -7
  12. package/index.test.ts +354 -25
  13. package/index.ts +174 -8
  14. package/linkValidation.test.ts +1 -0
  15. package/mcp.test.ts +30 -0
  16. package/mcp.ts +9 -0
  17. package/package.json +1 -1
  18. package/smokeTest.ts +16 -0
  19. package/test/dbFixture.ts +8 -0
  20. package/util.test.ts +426 -127
  21. package/util.ts +389 -196
  22. package/web/ai0BalanceForm/ai0BalanceForm.test.ts +16 -10
  23. package/web/ai0BalanceForm/index.html +1 -1
  24. package/web/ai0BalanceForm/index.ts +10 -6
  25. package/web/enqueue/index.ts +5 -2
  26. package/web/expungeReportsForm/expungeReportsForm.test.ts +19 -11
  27. package/web/expungeReportsForm/index.html +1 -1
  28. package/web/expungeReportsForm/index.ts +1 -1
  29. package/web/hideReportForm/hideReportForm.test.ts +24 -9
  30. package/web/hideReportForm/index.html +1 -1
  31. package/web/hideReportForm/index.ts +9 -6
  32. package/web/listReports/index.ts +33 -12
  33. package/web/listReports/listReports.test.ts +1 -1
  34. package/web/listTopIssues/index.ts +1 -1
  35. package/web/manage/index.html +2 -1
  36. package/web/metrics/index.html +19 -0
  37. package/web/metrics/index.ts +126 -0
  38. package/web/metrics/metrics.test.ts +211 -0
  39. package/web/pruneReportsForm/index.html +1 -1
  40. package/web/pruneReportsForm/index.ts +1 -1
  41. package/web/pruneReportsForm/pruneReportsForm.test.ts +18 -10
  42. package/web/reannotate/index.ts +7 -3
  43. package/web/reannotate/reannotate.test.ts +1 -1
  44. package/web/renewWCAG/index.ts +4 -1
  45. package/web/renewWCAG/renewWCAG.test.ts +1 -1
  46. package/web/reportDeletion.ts +12 -7
  47. package/web/requestRetest/index.html +3 -3
  48. package/web/requestRetest/index.ts +47 -16
  49. package/web/requestRetest/requestRetest.test.ts +38 -24
  50. package/web/requestTest/index.html +4 -4
  51. package/web/requestTest/index.ts +37 -9
  52. package/web/requestTest/requestTest.test.ts +21 -6
  53. package/web/rewindReportsForm/index.html +1 -1
  54. package/web/rewindReportsForm/index.ts +1 -1
  55. package/web/rewindReportsForm/rewindReportsForm.test.ts +18 -10
  56. package/web/showHiddenReportsForm/index.html +24 -0
  57. package/web/showHiddenReportsForm/index.ts +45 -0
  58. package/web/showHiddenReportsForm/showHiddenReportsForm.test.ts +103 -0
  59. package/web/tutorialAI/index.ts +1 -1
  60. package/web/tutorialWeb/index.ts +1 -1
  61. package/web/unhideReportForm/index.html +1 -1
  62. package/web/unhideReportForm/index.ts +11 -8
  63. package/web/unhideReportForm/unhideReportForm.test.ts +19 -11
@@ -0,0 +1,211 @@
1
+ /*
2
+ metrics.test.ts
3
+ Unit tests for web/metrics/index.ts.
4
+ */
5
+
6
+ // IMPORTS
7
+
8
+ import {test, before, after} from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import fs from 'node:fs/promises';
11
+ import {parse} from 'node-html-parser';
12
+ import {answer} from './index.ts';
13
+ import {getJSON, metricsPath} from '../../util.ts';
14
+
15
+ // SETUP AND TEARDOWN
16
+
17
+ const savedAuthCode = process.env.AUTH_CODE;
18
+ const savedDBDir = process.env.DB_DIR;
19
+ import {fixtureDBDir} from '../../test/dbFixture.ts';
20
+
21
+ before(() => {
22
+ process.env.AUTH_CODE = 'test-auth-code';
23
+ process.env.DB_DIR = fixtureDBDir;
24
+ });
25
+
26
+ after(() => {
27
+ if (savedAuthCode !== undefined) {
28
+ process.env.AUTH_CODE = savedAuthCode;
29
+ }
30
+ if (savedDBDir !== undefined) {
31
+ process.env.DB_DIR = savedDBDir;
32
+ }
33
+ });
34
+
35
+ // TESTS
36
+
37
+ test('metrics returns an error for an invalid auth code on a POST request', async () => {
38
+ const result: any = await answer(null, 'authCode=wrong', 'POST');
39
+ assert.equal(result.status, 'error');
40
+ assert.equal(result.message, 'Invalid request');
41
+ });
42
+
43
+ test('metrics displays a form requesting an auth code on a GET request', async () => {
44
+ const result: any = await answer(null, '', 'GET');
45
+ assert.equal(result.status, 'ok');
46
+ const html = parse(result.answerPage);
47
+ const input = html.querySelector('input[name="authCode"]');
48
+ assert.ok(input);
49
+ assert.equal(html.querySelectorAll('table').length, 0);
50
+ });
51
+
52
+ test('metrics displays a clearCounts checkbox in the initial form', async () => {
53
+ const result: any = await answer(null, '', 'GET');
54
+ const html = parse(result.answerPage);
55
+ const checkbox = html.querySelector('input[name="clearCounts"]');
56
+ assert.ok(checkbox);
57
+ assert.equal(checkbox.getAttribute('type'), 'checkbox');
58
+ });
59
+
60
+ test('metrics submits its form as a POST', async () => {
61
+ const result: any = await answer(null, '', 'GET');
62
+ const html = parse(result.answerPage);
63
+ const form = html.querySelector('form');
64
+ assert.equal(form?.getAttribute('method'), 'post');
65
+ });
66
+
67
+ test('metrics ignores a valid authCode query string on a GET request', async () => {
68
+ const result: any = await answer(null, 'authCode=test-auth-code', 'GET');
69
+ assert.equal(result.status, 'ok');
70
+ const html = parse(result.answerPage);
71
+ // The form (not the counts) should still be shown, since only POST submits.
72
+ assert.ok(html.querySelector('input[name="authCode"]'));
73
+ assert.equal(html.querySelectorAll('table').length, 0);
74
+ });
75
+
76
+ test('metrics ignores clearCounts on a GET request, even with a valid authCode', async () => {
77
+ await fs.writeFile(metricsPath(), getJSON({
78
+ since: '260101T0000',
79
+ pageViews: {tutorialWeb: 9},
80
+ mcpToolCalls: {},
81
+ apiOperations: {},
82
+ managerActivity: {}
83
+ }));
84
+ const result: any = await answer(null, 'authCode=test-auth-code&clearCounts=on', 'GET');
85
+ assert.ok(!result.answerPage.includes('Counts cleared.'));
86
+ const metrics = JSON.parse(await fs.readFile(metricsPath(), 'utf8'));
87
+ // The counts should NOT have been cleared by a mere GET request.
88
+ assert.equal(metrics.pageViews.tutorialWeb, 9);
89
+ await fs.rm(metricsPath(), {force: true});
90
+ });
91
+
92
+ test('metrics reports no counts yet when the metrics file does not exist', async () => {
93
+ await fs.rm(metricsPath(), {force: true});
94
+ const result: any = await answer(null, 'authCode=test-auth-code', 'POST');
95
+ assert.equal(result.status, 'ok');
96
+ assert.ok(result.answerPage.includes('Counts recorded since'));
97
+ const html = parse(result.answerPage);
98
+ assert.equal(html.querySelectorAll('table').length, 0);
99
+ await fs.rm(metricsPath(), {force: true});
100
+ });
101
+
102
+ test('metrics reports recorded counts with a valid auth code on a POST request', async () => {
103
+ await fs.writeFile(metricsPath(), getJSON({
104
+ since: '260101T0000',
105
+ pageViews: {tutorialWeb: 3},
106
+ mcpToolCalls: {listReports: 5},
107
+ apiOperations: {},
108
+ managerActivity: {}
109
+ }));
110
+ const result: any = await answer(null, 'authCode=test-auth-code', 'POST');
111
+ assert.equal(result.status, 'ok');
112
+ const html = parse(result.answerPage);
113
+ const tables = html.querySelectorAll('table');
114
+ assert.equal(tables.length, 2);
115
+ assert.ok(result.answerPage.includes('tutorialWeb'));
116
+ assert.ok(result.answerPage.includes('5'));
117
+ assert.ok(result.answerPage.includes('at 00:00'));
118
+ await fs.rm(metricsPath(), {force: true});
119
+ });
120
+
121
+ test('metrics sorts each category by descending count', async () => {
122
+ await fs.writeFile(metricsPath(), getJSON({
123
+ since: '260101T0000',
124
+ pageViews: {manage: 1, tutorialWeb: 9, otherPage: 4},
125
+ mcpToolCalls: {},
126
+ apiOperations: {},
127
+ managerActivity: {}
128
+ }));
129
+ const result: any = await answer(null, 'authCode=test-auth-code', 'POST');
130
+ const html = parse(result.answerPage);
131
+ const names = html.querySelectorAll('table')[0]!.querySelectorAll('tbody td:first-child')
132
+ .map(cell => cell.text);
133
+ assert.deepEqual(names, ['tutorialWeb', 'otherPage', 'manage']);
134
+ await fs.rm(metricsPath(), {force: true});
135
+ });
136
+
137
+ test('metrics reports manager page activity, sorted by descending failure count', async () => {
138
+ await fs.writeFile(metricsPath(), getJSON({
139
+ since: '260101T0000',
140
+ pageViews: {},
141
+ mcpToolCalls: {},
142
+ apiOperations: {},
143
+ managerActivity: {
144
+ metrics: {ok: 5, error: 0},
145
+ reannotate: {ok: 1, error: 8}
146
+ }
147
+ }));
148
+ const result: any = await answer(null, 'authCode=test-auth-code', 'POST');
149
+ assert.ok(result.answerPage.includes('Manager page activity'));
150
+ const html = parse(result.answerPage);
151
+ const tables = html.querySelectorAll('table');
152
+ assert.equal(tables.length, 1);
153
+ const managerTable = tables[0]!;
154
+ const rows = managerTable.querySelectorAll('tbody tr').map(row =>
155
+ row.querySelectorAll('td').map(cell => cell.text)
156
+ );
157
+ assert.deepEqual(rows, [['reannotate', '1', '8'], ['metrics', '5', '0']]);
158
+ await fs.rm(metricsPath(), {force: true});
159
+ });
160
+
161
+ test('metrics reports no manager activity yet when none has been recorded', async () => {
162
+ await fs.writeFile(metricsPath(), getJSON({
163
+ since: '260101T0000',
164
+ pageViews: {},
165
+ mcpToolCalls: {},
166
+ apiOperations: {},
167
+ managerActivity: {}
168
+ }));
169
+ const result: any = await answer(null, 'authCode=test-auth-code', 'POST');
170
+ const html = parse(result.answerPage);
171
+ assert.equal(html.querySelectorAll('table').length, 0);
172
+ assert.ok(result.answerPage.includes('None yet.'));
173
+ await fs.rm(metricsPath(), {force: true});
174
+ });
175
+
176
+ test('metrics clears all counts and shows a confirmation when clearCounts is submitted via POST', async () => {
177
+ await fs.writeFile(metricsPath(), getJSON({
178
+ since: '260101T0000',
179
+ pageViews: {tutorialWeb: 9},
180
+ mcpToolCalls: {listReports: 3},
181
+ apiOperations: {getReport: 1},
182
+ managerActivity: {reannotate: {ok: 1, error: 2}}
183
+ }));
184
+ const result: any = await answer(null, 'authCode=test-auth-code&clearCounts=on', 'POST');
185
+ assert.equal(result.status, 'ok');
186
+ assert.ok(result.answerPage.includes('Counts cleared.'));
187
+ const html = parse(result.answerPage);
188
+ assert.equal(html.querySelectorAll('table').length, 0);
189
+ const metrics = JSON.parse(await fs.readFile(metricsPath(), 'utf8'));
190
+ assert.deepEqual(metrics.pageViews, {});
191
+ assert.deepEqual(metrics.mcpToolCalls, {});
192
+ assert.deepEqual(metrics.apiOperations, {});
193
+ assert.deepEqual(metrics.managerActivity, {});
194
+ assert.notEqual(metrics.since, '260101T0000');
195
+ await fs.rm(metricsPath(), {force: true});
196
+ });
197
+
198
+ test('metrics does not clear counts, and does not show a confirmation, when clearCounts is not submitted', async () => {
199
+ await fs.writeFile(metricsPath(), getJSON({
200
+ since: '260101T0000',
201
+ pageViews: {tutorialWeb: 9},
202
+ mcpToolCalls: {},
203
+ apiOperations: {},
204
+ managerActivity: {}
205
+ }));
206
+ const result: any = await answer(null, 'authCode=test-auth-code', 'POST');
207
+ assert.ok(!result.answerPage.includes('Counts cleared.'));
208
+ const metrics = JSON.parse(await fs.readFile(metricsPath(), 'utf8'));
209
+ assert.equal(metrics.pageViews.tutorialWeb, 9);
210
+ await fs.rm(metricsPath(), {force: true});
211
+ });
@@ -14,7 +14,7 @@
14
14
  <main>
15
15
  <h1><a href="/">Kilotest</a>: Prune reports</h1>
16
16
  <p>__intro__</p>
17
- <form action="/pruneReportsForm.html">
17
+ <form action="/pruneReportsForm.html" method="post">
18
18
  <fieldset>
19
19
  <legend>Reports</legend>
20
20
  __reports__
@@ -10,7 +10,7 @@ import {reportDeletionForm} from '../reportDeletion.ts';
10
10
  // FUNCTIONS
11
11
 
12
12
  // Returns a form for deleting non-latest reports.
13
- export const answer = async (_: any, search: string) => reportDeletionForm(import.meta.dirname, search, {
13
+ export const answer = async (_: any, search: string, method: string) => reportDeletionForm(import.meta.dirname, search, method, {
14
14
  intro: 'Choose the superseded reports to delete.',
15
15
  emptyIntro: 'Each target has only 1 report, so there are no superseded reports to delete.',
16
16
  failurePrefix: 'Deleting superseded reports failed',
@@ -35,32 +35,40 @@ after(async () => {
35
35
 
36
36
  // TESTS
37
37
 
38
- test('pruneReportsForm displays a list of reports when no submission', async () => {
39
- const result = await answer(null, '');
38
+ test('pruneReportsForm displays a list of reports on a GET request', async () => {
39
+ const result = await answer(null, '', 'GET');
40
40
  assert.equal(result.status, 'ok');
41
41
  assert.ok(result.answerPage);
42
42
  const html = parse(result.answerPage);
43
43
  assert.ok(html.querySelector('title'));
44
44
  });
45
45
 
46
- test('pruneReportsForm returns an error for an invalid auth code', async () => {
47
- const result = await answer(null, 'authCode=wrong&report=260101T0000-mix');
46
+ test('pruneReportsForm ignores a report query string on a GET request', async () => {
47
+ const reportPath = path.join(reportsPath(), '260101T0000-mix.json');
48
+ const result = await answer(null, 'authCode=test-auth-code&report=260101T0000-mix', 'GET');
49
+ assert.equal(result.status, 'ok');
50
+ // The report should NOT have been deleted by a mere GET request.
51
+ await fs.access(reportPath);
52
+ });
53
+
54
+ test('pruneReportsForm returns an error for an invalid auth code on a POST request', async () => {
55
+ const result = await answer(null, 'authCode=wrong&report=260101T0000-mix', 'POST');
48
56
  assert.equal(result.status, 'error');
49
- assert.equal(result.message, 'Invalid authorization code');
57
+ assert.equal(result.message, 'Invalid request');
50
58
  });
51
59
 
52
- test('pruneReportsForm returns an error when deleting a nonexistent report', async () => {
53
- const result = await answer(null, 'authCode=test-auth-code&report=999999T9999-nope');
60
+ test('pruneReportsForm returns an error when deleting a nonexistent report on a POST request', async () => {
61
+ const result = await answer(null, 'authCode=test-auth-code&report=999999T9999-nope', 'POST');
54
62
  assert.equal(result.status, 'error');
55
63
  assert.ok(result.message?.includes('Deleting superseded reports'));
56
64
  });
57
65
 
58
- test('pruneReportsForm deletes a superseded report with valid auth code', async () => {
66
+ test('pruneReportsForm deletes a superseded report with valid auth code on a POST request', async () => {
59
67
  // 260101T0000-mix is superseded by 260202T0000-new (same URL).
60
68
  const reportPath = path.join(reportsPath(), '260101T0000-mix.json');
61
69
  const backup = await fs.readFile(reportPath, 'utf8');
62
70
  try {
63
- const result = await answer(null, 'authCode=test-auth-code&report=260101T0000-mix');
71
+ const result = await answer(null, 'authCode=test-auth-code&report=260101T0000-mix', 'POST');
64
72
  assert.equal(result.status, 'ok');
65
73
  await fs.access(reportPath).then(
66
74
  () => {throw new Error('Report should have been deleted');},
@@ -77,7 +85,7 @@ test('pruneReportsForm returns an error when a report file is corrupt', async ()
77
85
  const backup = await fs.readFile(reportPath, 'utf8');
78
86
  try {
79
87
  await fs.writeFile(reportPath, 'not valid json');
80
- const result = await answer(null, '');
88
+ const result = await answer(null, '', 'GET');
81
89
  assert.equal(result.status, 'error');
82
90
  }
83
91
  finally {
@@ -11,6 +11,7 @@ import {
11
11
  getReport,
12
12
  getReportPath,
13
13
  isReportError,
14
+ readdirOrCreate,
14
15
  reportsPath
15
16
  } from '../../util.ts';
16
17
  import fs from 'node:fs/promises';
@@ -23,7 +24,7 @@ export const answer = async (authCode: string) => {
23
24
  // If the authorization code is valid:
24
25
  if (authCode === process.env.AUTH_CODE) {
25
26
  // Get the names of the stored report files.
26
- const reportFileNames = (await fs.readdir(reportsPath()))
27
+ const reportFileNames = (await readdirOrCreate(reportsPath(), 'Reports directory'))
27
28
  .filter(fileName => fileName.endsWith('.json'));
28
29
  // If any exist:
29
30
  if (reportFileNames.length) {
@@ -62,9 +63,12 @@ export const answer = async (authCode: string) => {
62
63
  answerPage
63
64
  };
64
65
  }
65
- // Otherwise, i.e. if the authorization code is invalid, return an error page.
66
+ // Otherwise, i.e. if the authorization code is invalid, return an error page. The
67
+ // message is deliberately vague so as not to confirm to an attacker that the
68
+ // authorization code specifically (as opposed to some other part of the request) is
69
+ // what was wrong.
66
70
  return {
67
71
  status: 'error',
68
- message: 'Invalid authorization code'
72
+ message: 'Invalid request'
69
73
  };
70
74
  };
@@ -59,7 +59,7 @@ test('answer returns an error for an invalid authorization code', async () => {
59
59
  const {answer} = await import('./index.ts');
60
60
  const result = await answer('wrong-code');
61
61
  assert.equal(result.status, 'error');
62
- assert.equal(result.message, 'Invalid authorization code');
62
+ assert.equal(result.message, 'Invalid request');
63
63
  });
64
64
 
65
65
  test('answer returns an error when no reports are available', async () => {
@@ -61,10 +61,13 @@ export const answer = async (authCode: string) => {
61
61
  };
62
62
  }
63
63
  }
64
+ // The message is deliberately vague so as not to confirm to an attacker that the
65
+ // authorization code specifically (as opposed to some other part of the request) is
66
+ // what was wrong.
64
67
  else {
65
68
  return {
66
69
  status: 'error',
67
- message: 'Authorization code invalid'
70
+ message: 'Invalid request'
68
71
  };
69
72
  }
70
73
  };
@@ -65,7 +65,7 @@ test('answer returns an error for an invalid authorization code', async () => {
65
65
  const {answer} = await import('./index.ts');
66
66
  const result = await answer('wrong-code');
67
67
  assert.equal(result.status, 'error');
68
- assert.equal(result.message, 'Authorization code invalid');
68
+ assert.equal(result.message, 'Invalid request');
69
69
  });
70
70
 
71
71
  test('answer returns an error when the WCAG map source returns a non-200 status', async () => {
@@ -6,7 +6,7 @@
6
6
  // IMPORTS
7
7
 
8
8
  import {
9
- errorMessage, getReportData, objectSort, populateTemplate, reportsPath
9
+ errorMessage, getReportData, objectSort, populateTemplate, readdirOrCreate, reportsPath
10
10
  } from '../util.ts';
11
11
  import fs from 'node:fs/promises';
12
12
  import path from 'node:path';
@@ -32,18 +32,21 @@ export type DeletionSpec = {
32
32
 
33
33
  // FUNCTIONS
34
34
 
35
- // Returns a form for deleting the reports that the predicate marks as deletable.
35
+ // Returns a form for deleting the reports that the predicate marks as deletable. A GET
36
+ // request never processes a submission, regardless of its query string; only a POST
37
+ // request (the form's own submission) does.
36
38
  export const reportDeletionForm = async (
37
39
  dirName: string,
38
40
  search: string,
41
+ method: string,
39
42
  deletionSpec: DeletionSpec
40
43
  ): Promise<{status: string; message?: string; answerPage?: string}> => {
41
44
  const {emptyIntro, failurePrefix, intro, isDeletable} = deletionSpec;
42
45
  const searchParams = new URLSearchParams(search);
43
46
  const authCode = searchParams?.get('authCode');
44
47
  const jobNames = searchParams?.getAll('report');
45
- // If the form has been displayed by itself after a submission and any reports are to be deleted:
46
- if (jobNames?.length) {
48
+ // If the form has been submitted and any reports are to be deleted:
49
+ if (method === 'POST' && jobNames?.length) {
47
50
  // If the authorization code is valid:
48
51
  if (authCode === process.env.AUTH_CODE) {
49
52
  try {
@@ -64,14 +67,16 @@ export const reportDeletionForm = async (
64
67
  }
65
68
  // Otherwise, i.e. if the authorization code is invalid:
66
69
  else {
67
- // Report the error.
70
+ // Report the error, deliberately vague so as not to confirm to an attacker that the
71
+ // authorization code specifically (as opposed to some other part of the request) is
72
+ // what was wrong.
68
73
  return {
69
74
  status: 'error',
70
- message: 'Invalid authorization code'
75
+ message: 'Invalid request'
71
76
  };
72
77
  }
73
78
  }
74
- const reportNames = await fs.readdir(reportsPath());
79
+ const reportNames = await readdirOrCreate(reportsPath(), 'Reports directory');
75
80
  // Initialize an array of report summaries.
76
81
  const reportSpecs: ReportSpec[] = [];
77
82
  // For each report:
@@ -12,9 +12,9 @@
12
12
  </head>
13
13
  <body>
14
14
  <main>
15
- <h1><a href="/">Kilotest</a>: Retest request received for __target__</h1>
16
- <p>Your request to retest the __target__ page has been received.</p>
17
- <p>The reason for your request has been recorded as <q>__why__</q>.</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
18
  <p>Thank you for your request.</p>
19
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
20
  <p>In most cases, if you check Kilotest again in 1 day, you will find that the requested test has been performed.</p>
@@ -5,27 +5,58 @@
5
5
 
6
6
  // IMPORTS
7
7
 
8
- import {getReportExtracts, processTestRequest} from '../../util.ts';
8
+ import {populateTemplate, processTestRequest} from '../../util.ts';
9
9
 
10
10
  // FUNCTIONS
11
11
 
12
- export const answer = async (pageArgs: string, why: string) => {
13
- const [timeStamp, jobID] = pageArgs.split('/');
14
- // Get data on the latest available reports.
15
- const reportExtracts = await getReportExtracts(true);
16
- // Get data on the report whose page is to be retested.
17
- const reportExtract = reportExtracts.find(
18
- (extract) => extract.timeStamp === timeStamp && extract.jobID === jobID
19
- );
20
- // If no matching report was found:
21
- if (!reportExtract) {
22
- // Return why.
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.
23
19
  return {
24
20
  status: 'error',
25
- message: `No report found for ${timeStamp}-${jobID}`
21
+ message: 'Invalid request'
26
22
  };
27
23
  }
28
- const {url, description} = reportExtract;
29
- // Otherwise, i.e. if it succeeded, process the request.
30
- return await processTestRequest('retest', import.meta.dirname, description, url, why);
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
+ }
31
62
  };
@@ -68,11 +68,14 @@ test('answer returns ok with a populated answer page for a valid retest request'
68
68
  const result: any = await answer('260101T0001/ct', 'Because changes were made');
69
69
  assert.equal(result.status, 'ok');
70
70
  assert.ok(result.answerPage, 'answerPage should be present');
71
- // Verify the placeholders were replaced with the report target.
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.)
72
74
  assert.ok(result.answerPage.includes('All CantTell Page'));
73
75
  assert.ok(result.answerPage.includes('Because changes were made'));
74
- assert.ok(!result.answerPage.includes('__target__'));
75
- assert.ok(!result.answerPage.includes('__why__'));
76
+ assert.ok(!result.answerPage.includes('__description__'));
77
+ assert.ok(!result.answerPage.includes('__url__'));
78
+ assert.ok(!result.answerPage.includes('__reason__'));
76
79
  // Verify the HTML is well-formed.
77
80
  const doc = parse(result.answerPage);
78
81
  const h1 = doc.querySelector('h1');
@@ -85,32 +88,43 @@ test('answer returns an error when the report does not exist', async () => {
85
88
  // Use a nonexistent report timestamp and jobID.
86
89
  const result: any = await answer('990101T0000/xxx', 'Because changes were made');
87
90
  assert.equal(result.status, 'error');
88
- assert.ok(result.message?.includes('No report found'), `Expected no-report-found message, got: ${result.message}`);
91
+ assert.equal(result.message, 'Invalid request');
89
92
  });
90
93
 
91
- test('answer returns an error for an invalid retest request', async () => {
94
+ test('answer returns an error for a duplicate retest request', async () => {
92
95
  const {answer} = await import('./index.ts');
93
- // Use a valid report but a too-short reason.
94
- const result = await answer('260101T0001/ct', 'why');
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');
95
99
  assert.equal(result.status, 'error');
96
- assert.equal(result.message, 'Invalid request');
100
+ assert.equal(result.message, 'Test request duplicates an already submitted request');
97
101
  });
98
102
 
99
- test('answer returns an error when no extracts are available for the report', async (t) => {
100
- // Mock getReportExtracts to return an empty array, delegating other exports.
101
- // (getReportExtracts no longer returns error objects; reports not in the list
102
- // are treated as not found.)
103
- const realUtil = await import('../../util.ts');
104
- t.mock.module('../../util.ts', {
105
- exports: {
106
- ...realUtil,
107
- getReportExtracts: async () => []
108
- }
109
- } as any);
110
- // Import a fresh instance of index.ts so it binds to the mocked module.
111
- // The query string makes the specifier unique, bypassing the module cache.
112
- const {answer} = await import('./index.ts?mockNoExtracts' as any);
113
- const result: any = await answer('260101T0001/ct', 'Because changes were made');
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');
114
107
  assert.equal(result.status, 'error');
115
- assert.ok(result.message?.includes('No report found'), `Expected no-report-found message, got: ${result.message}`);
108
+ assert.equal(result.message, 'A later report about the page is already available');
116
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
+
@@ -6,15 +6,15 @@
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__ test request received | Kilotest</title>
9
+ <title>__description__ test 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>: Test request received for __target__</h1>
16
- <p>Your request to test the __target__ page has been received.</p>
17
- <p>The reason for your request has been recorded as <q>__why__</q>.</p>
15
+ <h1><a href="/">Kilotest</a>: Test request received for __description__</h1>
16
+ <p>Your request to test 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
18
  <p>Thank you for your request.</p>
19
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
20
  <p>In most cases, if you check Kilotest again in 1 day, you will find that the requested job has been performed.</p>