@jrpool/kilotest 58.2.4 → 59.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api/getReport.test.ts +1 -1
- package/api/listIssues.test.ts +32 -0
- package/api/listIssues.ts +38 -8
- package/api/listPages.test.ts +92 -0
- package/api/listPages.ts +93 -0
- package/api/orderNewTest.test.ts +2 -2
- package/api/orderNewTest.ts +4 -4
- package/api/orderRetest.test.ts +1 -1
- package/api/requestNewTest.test.ts +1 -1
- package/api/requestNewTest.ts +6 -6
- package/api/requestRetest.test.ts +3 -3
- package/api/requestRetest.ts +2 -2
- package/api/routes.test.ts +1 -1
- package/api/routes.ts +5 -5
- package/api/schemas.ts +24 -7
- package/api/util.ts +26 -45
- package/apiIntegration.test.ts +5 -5
- package/docs/AI-TOOLS.md +1 -1
- package/docs/SERVICE.md +16 -5
- package/index.test.ts +31 -19
- package/index.ts +25 -13
- package/lhm.plugin.json +1433 -141
- package/linkValidation.test.ts +2 -2
- package/llms-full.txt +9 -9
- package/llms.txt +4 -4
- package/mcp.test.ts +9 -9
- package/mcp.ts +14 -14
- package/openapi.yaml +84 -30
- package/package.json +1 -1
- package/routing.test.ts +5 -5
- package/server.json +1 -1
- package/sitemap.xml +1 -1
- package/smokeTest.ts +2 -2
- package/style.css +8 -8
- package/test/fixtures/buildFixtures.ts +3 -3
- package/test/fixtures/db/reports/260101T0000-mix.json +2 -2
- package/test/fixtures/db/reports/260202T0000-new.json +2 -2
- package/util.test.ts +31 -22
- package/util.ts +16 -5
- package/web/enqueueForm/index.html +1 -1
- package/web/hideReportForm/hideReportForm.test.ts +5 -0
- package/web/hideReportForm/index.ts +9 -6
- package/web/home/home.test.ts +19 -0
- package/{index.html → web/home/index.html} +2 -2
- package/web/home/index.ts +20 -0
- package/web/listDiagnoses/listDiagnoses.test.ts +1 -1
- package/web/listIssues/index.html +9 -10
- package/web/listIssues/index.ts +45 -3
- package/web/listIssues/listIssues.test.ts +82 -6
- package/web/{listReports → listPages}/index.html +1 -2
- package/web/listPages/index.ts +99 -0
- package/web/listPages/listPages.test.ts +196 -0
- package/web/listViolators/listViolators.test.ts +1 -1
- package/web/metrics/metrics.test.ts +2 -2
- package/web/requestNewTest/requestNewTest.test.ts +1 -1
- package/web/requestRetest/requestRetest.test.ts +1 -2
- package/web/requestRetestForm/index.ts +4 -2
- package/web/requestRetestForm/requestRetestForm.test.ts +12 -1
- package/web/unhideReportForm/index.ts +9 -6
- package/web/unhideReportForm/unhideReportForm.test.ts +8 -5
- package/web/util.test.ts +87 -23
- package/web/util.ts +27 -31
- package/api/listReports.test.ts +0 -109
- package/api/listReports.ts +0 -87
- package/web/listReports/index.ts +0 -182
- package/web/listReports/listReports.test.ts +0 -300
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/*
|
|
2
|
+
index.ts
|
|
3
|
+
Lists all tested pages, with links to the latest reports about them.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// IMPORTS
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
getJobNames,
|
|
10
|
+
getObject,
|
|
11
|
+
getReportExtracts,
|
|
12
|
+
getTestRequests,
|
|
13
|
+
jobsPath,
|
|
14
|
+
populateTemplate
|
|
15
|
+
} from '../../util.ts';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
// FUNCTIONS
|
|
19
|
+
|
|
20
|
+
// Adds parameters to a query for the answer page.
|
|
21
|
+
const populateQuery = async (query: Record<string, any>) => {
|
|
22
|
+
const margin = ' '.repeat(8);
|
|
23
|
+
// Initialize the classes of lines.
|
|
24
|
+
const lines: {requests: string[], queue: string[], claimed: string[], tested: string[]} = {
|
|
25
|
+
requests: [],
|
|
26
|
+
queue: [],
|
|
27
|
+
claimed: [],
|
|
28
|
+
tested: []
|
|
29
|
+
};
|
|
30
|
+
// Get the test requests.
|
|
31
|
+
const testRequests = await getTestRequests();
|
|
32
|
+
// For each requested URL:
|
|
33
|
+
Object.keys(testRequests).forEach(url => {
|
|
34
|
+
// For each of its requests:
|
|
35
|
+
testRequests[url]!.forEach((req: any) => {
|
|
36
|
+
const {description, reason} = req;
|
|
37
|
+
// Add a line.
|
|
38
|
+
lines.requests.push(`${margin}<li><code>${url}</code> (${description}): ${reason}</li>`);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
// Sort the lines in alphabetical order by URL and secondarily by proposed name.
|
|
42
|
+
lines.requests.sort();
|
|
43
|
+
// Add the lines to the query.
|
|
44
|
+
query.requests = lines.requests.join('\n');
|
|
45
|
+
// Add a no-requests message, if applicable, to the query.
|
|
46
|
+
query.noRequests = lines.requests.length
|
|
47
|
+
? 'Kilotest managers can <a href="enqueueForm.html">approve or reject a request</a>.'
|
|
48
|
+
: 'No requests await approval now.';
|
|
49
|
+
// Get the file names of all queued and claimed jobs.
|
|
50
|
+
const jobFileNames = await getJobNames();
|
|
51
|
+
// For each job category:
|
|
52
|
+
for (const category of ['queue', 'claimed'] as const) {
|
|
53
|
+
// For each job in the category:
|
|
54
|
+
for (const fileName of jobFileNames[category]) {
|
|
55
|
+
// Get the job.
|
|
56
|
+
const job = await getObject(path.join(jobsPath(), category, fileName)) as {target: {url: string, what: string}};
|
|
57
|
+
// Add a line.
|
|
58
|
+
lines[category].push(`${margin}<li><code>${job.target.url}</code> (${job.target.what})</li>`);
|
|
59
|
+
}
|
|
60
|
+
// Add the lines to the query.
|
|
61
|
+
query[category] = lines[category].join('\n');
|
|
62
|
+
}
|
|
63
|
+
// Add a no-queued message, if applicable, to the query.
|
|
64
|
+
query.noQueued = lines.queue.length ? '' : 'No pages are queued for testing.';
|
|
65
|
+
// Add a no-claimed message, if applicable, to the query.
|
|
66
|
+
query.noClaimed = lines.claimed.length ? '' : 'No pages are being tested now.';
|
|
67
|
+
// Get extracts of the latest reports about all tested pages, sorted by page description.
|
|
68
|
+
const latestExtracts = await getReportExtracts(true);
|
|
69
|
+
const pageCount = latestExtracts.length;
|
|
70
|
+
// For each tested page:
|
|
71
|
+
for (const extract of latestExtracts) {
|
|
72
|
+
const {jobID, timeStamp, description} = extract;
|
|
73
|
+
// Add a link to the issues in the latest report about it to the lines.
|
|
74
|
+
const link = `<a href="listIssues.html/${timeStamp}/${jobID}" aria-label="Get test results for ${description}">${description}</a>`;
|
|
75
|
+
lines.tested.push(`${margin} <li>${link}</li>`);
|
|
76
|
+
}
|
|
77
|
+
// Add the lines to the query, as an introduced list if there are any, or else a no-pages message.
|
|
78
|
+
query.testedPages = pageCount
|
|
79
|
+
? [
|
|
80
|
+
`${margin}<p>Get test results for:</p>`,
|
|
81
|
+
`${margin}<ul class="links">`,
|
|
82
|
+
...lines.tested,
|
|
83
|
+
`${margin}</ul>`
|
|
84
|
+
].join('\n')
|
|
85
|
+
: `${margin}<p>No pages have been tested yet.</p>`;
|
|
86
|
+
};
|
|
87
|
+
// Returns a page listing the tested pages.
|
|
88
|
+
export const answer = async () => {
|
|
89
|
+
const query: Record<string, any> = {};
|
|
90
|
+
// Create a query to replace placeholders.
|
|
91
|
+
await populateQuery(query);
|
|
92
|
+
// Get the populated template.
|
|
93
|
+
const answerPage = await populateTemplate(import.meta.dirname, query);
|
|
94
|
+
// Return the populated page.
|
|
95
|
+
return {
|
|
96
|
+
status: 'ok',
|
|
97
|
+
answerPage
|
|
98
|
+
};
|
|
99
|
+
};
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/*
|
|
2
|
+
listPages.test.ts
|
|
3
|
+
UI tests for web/listPages/index.ts using the fixture corpus.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// IMPORTS
|
|
7
|
+
|
|
8
|
+
import {test, before, after} from 'node:test';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import fs from 'node:fs/promises';
|
|
12
|
+
import {parse} from 'node-html-parser';
|
|
13
|
+
import {answer} from './index.ts';
|
|
14
|
+
|
|
15
|
+
// SETUP AND TEARDOWN
|
|
16
|
+
|
|
17
|
+
const savedDBDir = process.env.DB_DIR;
|
|
18
|
+
|
|
19
|
+
before(async () => {
|
|
20
|
+
process.env.DB_DIR = (await import('../../test/dbFixture.ts')).fixtureDBDir;
|
|
21
|
+
// Ensure job subdirectories exist (they are empty and not tracked by Git).
|
|
22
|
+
for (const sub of ['queue', 'claimed', 'failed']) {
|
|
23
|
+
await fs.mkdir(path.join(process.env.DB_DIR, 'jobs', sub), {recursive: true});
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
after(() => {
|
|
28
|
+
if (savedDBDir !== undefined) {
|
|
29
|
+
process.env.DB_DIR = savedDBDir;
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
delete process.env.DB_DIR;
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// TESTS
|
|
37
|
+
|
|
38
|
+
test('listPages returns an ok status with valid HTML', async () => {
|
|
39
|
+
const result: any = await answer();
|
|
40
|
+
assert.equal(result.status, 'ok');
|
|
41
|
+
assert.ok(result.answerPage);
|
|
42
|
+
const html = parse(result.answerPage);
|
|
43
|
+
assert.equal(html.querySelector('title')?.text, 'Pages tested | Kilotest');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Returns the links in the list of tested pages.
|
|
47
|
+
const getPageLinks = (answerPage: string) => {
|
|
48
|
+
const html = parse(answerPage);
|
|
49
|
+
return html.querySelectorAll('main > ul > li > a');
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
test('listPages lists each of the 7 tested pages once, by description', async () => {
|
|
53
|
+
const result: any = await answer();
|
|
54
|
+
const links = getPageLinks(result.answerPage);
|
|
55
|
+
assert.deepEqual(
|
|
56
|
+
links.map(a => a.text),
|
|
57
|
+
[
|
|
58
|
+
'All CantTell Page',
|
|
59
|
+
'Branch Coverage Page',
|
|
60
|
+
'Empty Results Page',
|
|
61
|
+
'Mixed Outcomes',
|
|
62
|
+
'Multi Violator Page',
|
|
63
|
+
'No Outcomes Page',
|
|
64
|
+
'Prevented Page'
|
|
65
|
+
]
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('listPages introduces the list of tested pages', async () => {
|
|
70
|
+
const result: any = await answer();
|
|
71
|
+
assert.ok(result.answerPage.includes('<p>Get test results for:</p>'));
|
|
72
|
+
assert.ok(!result.answerPage.includes('No pages have been tested yet.'));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('listPages links each page to the issues in its latest report', async () => {
|
|
76
|
+
const result: any = await answer();
|
|
77
|
+
const links = getPageLinks(result.answerPage);
|
|
78
|
+
const mixedLink = links.find(a => a.text === 'Mixed Outcomes');
|
|
79
|
+
assert.equal(mixedLink?.getAttribute('href'), 'listIssues.html/260202T0000/new');
|
|
80
|
+
const canttellLink = links.find(a => a.text === 'All CantTell Page');
|
|
81
|
+
assert.equal(canttellLink?.getAttribute('href'), 'listIssues.html/260101T0001/ct');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('listPages contains no report summaries', async () => {
|
|
85
|
+
const result: any = await answer();
|
|
86
|
+
const html = parse(result.answerPage);
|
|
87
|
+
assert.equal(html.querySelectorAll('details').length, 0);
|
|
88
|
+
assert.ok(!result.answerPage.includes('https://example.com/mixed'));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('listPages does not include the hidden report', async () => {
|
|
92
|
+
const result: any = await answer();
|
|
93
|
+
assert.ok(!result.answerPage.includes('Hidden Page'));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('listPages includes a link to request testing a new page', async () => {
|
|
97
|
+
const result: any = await answer();
|
|
98
|
+
const html = parse(result.answerPage);
|
|
99
|
+
const testLink = html.querySelector('a[href="requestNewTestForm.html"]');
|
|
100
|
+
assert.ok(testLink);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('listPages shows requests when testRequests.json has entries', {timeout: 500}, async () => {
|
|
104
|
+
const dbDir = (await import('../../test/dbFixture.ts')).fixtureDBDir;
|
|
105
|
+
const testRequestsPath = path.join(dbDir, 'jobs', 'testRequests.json');
|
|
106
|
+
const originalTestRequests = await fs.readFile(testRequestsPath, 'utf8');
|
|
107
|
+
try {
|
|
108
|
+
const testRequests = {
|
|
109
|
+
'https://example.com/mixed': [
|
|
110
|
+
{description: 'Mixed Outcomes', reason: 'Needs retesting for accessibility'}
|
|
111
|
+
]
|
|
112
|
+
};
|
|
113
|
+
await fs.writeFile(testRequestsPath, JSON.stringify(testRequests, null, 2));
|
|
114
|
+
const result: any = await answer();
|
|
115
|
+
assert.equal(result.status, 'ok');
|
|
116
|
+
assert.ok(result.answerPage.includes('https://example.com/mixed'));
|
|
117
|
+
assert.ok(result.answerPage.includes('Needs retesting for accessibility'));
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
await fs.writeFile(testRequestsPath, originalTestRequests);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('listPages shows queued and claimed jobs when they exist', async () => {
|
|
125
|
+
const dbDir = (await import('../../test/dbFixture.ts')).fixtureDBDir;
|
|
126
|
+
const queueDir = path.join(dbDir, 'jobs', 'queue');
|
|
127
|
+
const claimedDir = path.join(dbDir, 'jobs', 'claimed');
|
|
128
|
+
const queueFile = path.join(queueDir, 'queuedJob.json');
|
|
129
|
+
const claimedFile = path.join(claimedDir, 'claimedJob.json');
|
|
130
|
+
try {
|
|
131
|
+
const queuedJob = {
|
|
132
|
+
target: {url: 'https://example.com/queued', what: 'Queued Page'}
|
|
133
|
+
};
|
|
134
|
+
const claimedJob = {
|
|
135
|
+
target: {url: 'https://example.com/claimed', what: 'Claimed Page'}
|
|
136
|
+
};
|
|
137
|
+
await fs.writeFile(queueFile, JSON.stringify(queuedJob, null, 2));
|
|
138
|
+
await fs.writeFile(claimedFile, JSON.stringify(claimedJob, null, 2));
|
|
139
|
+
const result: any = await answer();
|
|
140
|
+
assert.equal(result.status, 'ok');
|
|
141
|
+
assert.ok(result.answerPage.includes('https://example.com/queued'));
|
|
142
|
+
assert.ok(result.answerPage.includes('Queued Page'));
|
|
143
|
+
assert.ok(result.answerPage.includes('https://example.com/claimed'));
|
|
144
|
+
assert.ok(result.answerPage.includes('Claimed Page'));
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
await fs.unlink(queueFile).catch(() => {});
|
|
148
|
+
await fs.unlink(claimedFile).catch(() => {});
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('listPages lists a page whose report is not usable', {timeout: 500}, async () => {
|
|
153
|
+
const dbDir = (await import('../../test/dbFixture.ts')).fixtureDBDir;
|
|
154
|
+
const reportsDir = path.join(dbDir, 'reports');
|
|
155
|
+
const invalidReportPath = path.join(reportsDir, '260101T9999-bad.json');
|
|
156
|
+
try {
|
|
157
|
+
// Valid for getReportExtract (has target and jobData.endTime) but not usable by isUsableReport (no acts or catalog).
|
|
158
|
+
const invalidReport = {
|
|
159
|
+
target: {what: 'Invalid Report', url: 'https://example.com/invalid'},
|
|
160
|
+
jobData: {endTime: '26-01-01T00:10'}
|
|
161
|
+
};
|
|
162
|
+
await fs.writeFile(invalidReportPath, JSON.stringify(invalidReport, null, 2));
|
|
163
|
+
const result: any = await answer();
|
|
164
|
+
assert.equal(result.status, 'ok');
|
|
165
|
+
const links = getPageLinks(result.answerPage);
|
|
166
|
+
assert.ok(links.some(a => a.text === 'Invalid Report'));
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
await fs.unlink(invalidReportPath).catch(() => {});
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('listPages shows no-reports message when the database is empty', async () => {
|
|
174
|
+
// Create a temp DB with empty reports and jobs directories.
|
|
175
|
+
const os = await import('node:os');
|
|
176
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'listpages-empty-'));
|
|
177
|
+
await fs.mkdir(path.join(tmpDir, 'reports'), {recursive: true});
|
|
178
|
+
await fs.mkdir(path.join(tmpDir, 'jobs', 'queue'), {recursive: true});
|
|
179
|
+
await fs.mkdir(path.join(tmpDir, 'jobs', 'claimed'), {recursive: true});
|
|
180
|
+
await fs.mkdir(path.join(tmpDir, 'jobs', 'failed'), {recursive: true});
|
|
181
|
+
await fs.writeFile(path.join(tmpDir, 'jobs', 'testRequests.json'), '{}\n');
|
|
182
|
+
const savedDBDir = process.env.DB_DIR;
|
|
183
|
+
process.env.DB_DIR = tmpDir;
|
|
184
|
+
try {
|
|
185
|
+
const {answer} = await import('./index.ts');
|
|
186
|
+
const result: any = await answer();
|
|
187
|
+
assert.equal(result.status, 'ok');
|
|
188
|
+
assert.ok(result.answerPage.includes('<p>No pages have been tested yet.</p>'));
|
|
189
|
+
assert.ok(!result.answerPage.includes('Get test results for:'));
|
|
190
|
+
assert.equal(getPageLinks(result.answerPage).length, 0);
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
process.env.DB_DIR = savedDBDir;
|
|
194
|
+
await fs.rm(tmpDir, {recursive: true}).catch(() => {});
|
|
195
|
+
}
|
|
196
|
+
});
|
|
@@ -188,7 +188,7 @@ test('listViolators includes take-me-there links for text-linkable violators', a
|
|
|
188
188
|
|
|
189
189
|
test('listViolators returns an error when report facts are not obtained', async () => {
|
|
190
190
|
pageDataStringsOverride = {
|
|
191
|
-
what: 'Mixed Outcomes
|
|
191
|
+
what: 'Mixed Outcomes',
|
|
192
192
|
url: 'https://example.com/mixed',
|
|
193
193
|
urlLink: '<a href="https://example.com/mixed">https://example.com/mixed</a>'
|
|
194
194
|
};
|
|
@@ -103,7 +103,7 @@ test('metrics reports recorded counts with a valid auth code on a POST request',
|
|
|
103
103
|
await fs.writeFile(metricsPath(), getJSON({
|
|
104
104
|
since: '260101T0000',
|
|
105
105
|
pageViews: {tutorialWeb: 3},
|
|
106
|
-
mcpToolCalls: {
|
|
106
|
+
mcpToolCalls: {listPages: 5},
|
|
107
107
|
apiOperations: {},
|
|
108
108
|
managerActivity: {}
|
|
109
109
|
}));
|
|
@@ -177,7 +177,7 @@ test('metrics clears all counts and shows a confirmation when clearCounts is sub
|
|
|
177
177
|
await fs.writeFile(metricsPath(), getJSON({
|
|
178
178
|
since: '260101T0000',
|
|
179
179
|
pageViews: {tutorialWeb: 9},
|
|
180
|
-
mcpToolCalls: {
|
|
180
|
+
mcpToolCalls: {listPages: 3},
|
|
181
181
|
apiOperations: {getReport: 1},
|
|
182
182
|
managerActivity: {reannotate: {ok: 1, error: 2}}
|
|
183
183
|
}));
|
|
@@ -123,7 +123,7 @@ test('answer returns an error when a report already exists for the page', {timeo
|
|
|
123
123
|
const {answer} = await import('./index.ts');
|
|
124
124
|
// The fixture database already has a report for this description and URL.
|
|
125
125
|
const result: any = await answer(
|
|
126
|
-
'Mixed Outcomes
|
|
126
|
+
'Mixed Outcomes', 'https://example.com/mixed', 'Because accessibility'
|
|
127
127
|
);
|
|
128
128
|
assert.equal(result.status, 'error');
|
|
129
129
|
assert.equal(result.message, 'A report about a page with the same description or URL is already available');
|
|
@@ -102,7 +102,7 @@ test('answer returns an error for a duplicate retest request', async () => {
|
|
|
102
102
|
|
|
103
103
|
test('answer returns an error when the cited report has been superseded', async () => {
|
|
104
104
|
const {answer} = await import('./index.ts');
|
|
105
|
-
// 260101T0000-mix (Mixed Outcomes
|
|
105
|
+
// 260101T0000-mix (Mixed Outcomes) is superseded by 260202T0000-new.
|
|
106
106
|
const result: any = await answer('260101T0000/mix', 'Because changes were made');
|
|
107
107
|
assert.equal(result.status, 'error');
|
|
108
108
|
assert.equal(result.message, 'A later report about the page is already available');
|
|
@@ -141,4 +141,3 @@ test('answer returns an error with a fallback channel when the request queue is
|
|
|
141
141
|
assert.ok(result.message.includes('https://github.com/jrpool/kilotest/issues'));
|
|
142
142
|
assert.ok(result.message.includes('info@kilotest.com'));
|
|
143
143
|
});
|
|
144
|
-
|
|
@@ -31,12 +31,14 @@ export const answer = async (pageArgs: string) => {
|
|
|
31
31
|
// Make the form report the failure.
|
|
32
32
|
target = 'The specified page is not available for retesting';
|
|
33
33
|
}
|
|
34
|
+
// Get the completion time of the report, or an invalid time if the report is unavailable.
|
|
35
|
+
const reportTime = new Date(reportExtract?.reportTime ?? NaN);
|
|
34
36
|
const query: Record<string, string> = {
|
|
35
37
|
target,
|
|
36
38
|
timeStamp,
|
|
37
39
|
jobID,
|
|
38
|
-
ago: getAgoString(
|
|
39
|
-
dateTime: getDateTimeString(
|
|
40
|
+
ago: getAgoString(reportTime),
|
|
41
|
+
dateTime: getDateTimeString(reportTime)
|
|
40
42
|
};
|
|
41
43
|
// Get the recommendation form template.
|
|
42
44
|
const answerPage = await populateTemplate(import.meta.dirname, query);
|
|
@@ -35,7 +35,7 @@ test('requestRetestForm returns ok with the page name for a valid report', async
|
|
|
35
35
|
assert.ok(result.answerPage);
|
|
36
36
|
const html = parse(result.answerPage);
|
|
37
37
|
const title = html.querySelector('title')?.text;
|
|
38
|
-
assert.ok(title?.includes('Mixed Outcomes
|
|
38
|
+
assert.ok(title?.includes('Mixed Outcomes'));
|
|
39
39
|
});
|
|
40
40
|
|
|
41
41
|
test('requestRetestForm populates the form action with the timeStamp and jobID', async () => {
|
|
@@ -51,6 +51,17 @@ test('requestRetestForm shows the fallback message for a nonexistent report', as
|
|
|
51
51
|
assert.ok(result.answerPage.includes('The specified page is not available for retesting'));
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
+
test('requestRetestForm dates the report by its completion time', async () => {
|
|
55
|
+
const result = await answer('260202T0000/new');
|
|
56
|
+
assert.ok(result.answerPage.includes('on 2026-02-02 at 00:10'));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('requestRetestForm describes the time of a nonexistent report as unknown', async () => {
|
|
60
|
+
const result = await answer('999999T9999/nope');
|
|
61
|
+
assert.ok(result.answerPage.includes('an unknown number of days ago'));
|
|
62
|
+
assert.ok(result.answerPage.includes('on an unknown date at an unknown time'));
|
|
63
|
+
});
|
|
64
|
+
|
|
54
65
|
test('requestRetestForm includes the ago and dateTime placeholders replaced', async () => {
|
|
55
66
|
const result = await answer('260202T0000/new');
|
|
56
67
|
assert.ok(!result.answerPage.includes('__ago__'));
|
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
// IMPORTS
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
|
-
hiddenReportsPath, isValidAuthCode, populateTemplate, readdirOrCreate, reportsPath
|
|
9
|
+
getReportEndTime, hiddenReportsPath, isValidAuthCode, populateTemplate, readdirOrCreate, reportsPath
|
|
10
10
|
} from '../../util.ts';
|
|
11
|
+
import {getDateTimeString} from '../util.ts';
|
|
11
12
|
import fs from 'node:fs/promises';
|
|
12
13
|
import path from 'node:path';
|
|
13
14
|
|
|
@@ -64,13 +65,14 @@ export const answer = async (_: any, search: string, method: string) => {
|
|
|
64
65
|
reportsData.push({
|
|
65
66
|
description,
|
|
66
67
|
timeStamp,
|
|
67
|
-
jobID
|
|
68
|
+
jobID,
|
|
69
|
+
reportTime: getReportEndTime(report)
|
|
68
70
|
});
|
|
69
71
|
}
|
|
70
|
-
// Sort the data by page name and then by time
|
|
72
|
+
// Sort the data by page name and then by completion time.
|
|
71
73
|
reportsData.sort((a, b) => {
|
|
72
74
|
if (a.description === b.description) {
|
|
73
|
-
return a.
|
|
75
|
+
return a.reportTime.getTime() - b.reportTime.getTime();
|
|
74
76
|
}
|
|
75
77
|
return a.description.localeCompare(b.description, 'en', {sensitivity: 'base'});
|
|
76
78
|
});
|
|
@@ -78,8 +80,9 @@ export const answer = async (_: any, search: string, method: string) => {
|
|
|
78
80
|
const margin = ' '.repeat(12);
|
|
79
81
|
// For each report:
|
|
80
82
|
reportsData.forEach(data => {
|
|
81
|
-
const {jobID, timeStamp, description} = data;
|
|
82
|
-
const
|
|
83
|
+
const {jobID, timeStamp, description, reportTime} = data;
|
|
84
|
+
const when = getDateTimeString(reportTime);
|
|
85
|
+
const specString = `${description} (job <code>${timeStamp}-${jobID}</code> completed ${when})`;
|
|
83
86
|
// Add a line with a radio button to unhide it.
|
|
84
87
|
lines.push(
|
|
85
88
|
`${margin}<p><input type="radio" name="report" value="${timeStamp}-${jobID}"> ${specString}</p>`
|
|
@@ -100,21 +100,24 @@ test('unhideReportForm returns an error when unhiding a nonexistent report on a
|
|
|
100
100
|
assert.ok(result.message.includes('Unhiding report'));
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
-
test('unhideReportForm sorts hidden reports with the same page name by
|
|
103
|
+
test('unhideReportForm sorts hidden reports with the same page name by completion time', async () => {
|
|
104
104
|
const {hidden} = getPaths();
|
|
105
105
|
// Add a second hidden report with the same what as the existing one.
|
|
106
106
|
const existing = JSON.parse(await fs.readFile(hidden, 'utf8'));
|
|
107
|
-
// Create a copy with a
|
|
107
|
+
// Create a copy with a later job time stamp but an earlier completion time.
|
|
108
108
|
const copy = JSON.parse(JSON.stringify(existing));
|
|
109
109
|
copy.id = '260101T0010-hid2';
|
|
110
|
+
copy.jobData.endTime = '26-01-01T00:05';
|
|
110
111
|
const copyPath = path.join(hiddenReportsPath(), '260101T0010-hid2.json');
|
|
111
112
|
await fs.writeFile(copyPath, JSON.stringify(copy));
|
|
112
113
|
try {
|
|
113
114
|
const result: any = await answer(null, '', 'GET');
|
|
114
115
|
assert.equal(result.status, 'ok');
|
|
115
|
-
// Both reports should appear in the form.
|
|
116
|
-
|
|
117
|
-
assert.ok(
|
|
116
|
+
// Both reports should appear in the form, in order of completion time.
|
|
117
|
+
const page: string = result.answerPage;
|
|
118
|
+
assert.ok(page.includes('(job <code>260101T0007-hid</code> completed 2026-01-01 at 00:10)'));
|
|
119
|
+
assert.ok(page.includes('(job <code>260101T0010-hid2</code> completed 2026-01-01 at 00:05)'));
|
|
120
|
+
assert.ok(page.indexOf('260101T0010-hid2') < page.indexOf('260101T0007-hid'));
|
|
118
121
|
}
|
|
119
122
|
finally {
|
|
120
123
|
await fs.unlink(copyPath).catch(() => {});
|
package/web/util.test.ts
CHANGED
|
@@ -9,14 +9,15 @@ import {test, before, after} from 'node:test';
|
|
|
9
9
|
import assert from 'node:assert/strict';
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import fs from 'node:fs/promises';
|
|
12
|
+
import {parse} from 'node-html-parser';
|
|
12
13
|
import {getReport, isReportError, reportsPath} from '../util.ts';
|
|
13
14
|
import {
|
|
14
15
|
checkCommentLength,
|
|
15
16
|
getAgoString,
|
|
16
17
|
getCountString,
|
|
18
|
+
getDateTimeString,
|
|
17
19
|
getEngineIDs,
|
|
18
20
|
getEngineNamesString,
|
|
19
|
-
getMultiReportWhats,
|
|
20
21
|
getPageData,
|
|
21
22
|
getPageDataStrings,
|
|
22
23
|
getPageFactsLines,
|
|
@@ -114,7 +115,7 @@ test('getWeightName returns the correct name for each weight', () => {
|
|
|
114
115
|
});
|
|
115
116
|
|
|
116
117
|
test('makeBreakable inserts wbr before non-initial slashes', () => {
|
|
117
|
-
assert.equal(makeBreakable('/api/
|
|
118
|
+
assert.equal(makeBreakable('/api/listPages'), '/api<wbr>/listPages');
|
|
118
119
|
});
|
|
119
120
|
|
|
120
121
|
// Returns a fixture report, failing the test if it is not usable.
|
|
@@ -128,7 +129,7 @@ const getFixtureReport = async (timeStamp: string, jobID: string) => {
|
|
|
128
129
|
|
|
129
130
|
test('getPageData returns page data for a valid report', async () => {
|
|
130
131
|
const data = getPageData(await getFixtureReport('260101T0000', 'mix'));
|
|
131
|
-
assert.equal(data.description, 'Mixed Outcomes
|
|
132
|
+
assert.equal(data.description, 'Mixed Outcomes');
|
|
132
133
|
assert.equal(data.url, 'https://example.com/mixed');
|
|
133
134
|
assert.equal(typeof data.daysAgo, 'number');
|
|
134
135
|
});
|
|
@@ -140,18 +141,29 @@ test('getReport returns an error for a nonexistent report', async () => {
|
|
|
140
141
|
|
|
141
142
|
test('getPageDataStrings returns HTML strings for a valid report', async () => {
|
|
142
143
|
const strings = getPageDataStrings(await getFixtureReport('260101T0000', 'mix'));
|
|
143
|
-
assert.equal(strings.description, 'Mixed Outcomes
|
|
144
|
+
assert.equal(strings.description, 'Mixed Outcomes');
|
|
144
145
|
assert.equal(strings.url, 'https://example.com/mixed');
|
|
145
146
|
assert.equal(strings.urlLink, '<a href="https://example.com/mixed">https://example.com/mixed</a>');
|
|
146
147
|
assert.ok(strings.testInfo.includes('by job <code>260101T0000-mix</code>'));
|
|
147
|
-
assert.ok(strings.testInfo.includes('2026-01-01 at 00:
|
|
148
|
+
assert.ok(strings.testInfo.includes('2026-01-01 at 00:10'));
|
|
148
149
|
});
|
|
149
150
|
|
|
150
|
-
test('getPageDataStrings
|
|
151
|
-
const
|
|
151
|
+
test('getPageDataStrings dates a report by its completion time, not by its job time stamp', async () => {
|
|
152
|
+
const report = await getFixtureReport('260101T0001', 'ct');
|
|
153
|
+
report.jobData.endTime = '26-03-04T05:06';
|
|
154
|
+
const strings = getPageDataStrings(report);
|
|
152
155
|
assert.equal(strings.description, 'All CantTell Page');
|
|
153
156
|
assert.ok(strings.testInfo.includes('by job <code>260101T0001-ct</code>'));
|
|
154
|
-
assert.ok(strings.testInfo.includes('2026-
|
|
157
|
+
assert.ok(strings.testInfo.includes('2026-03-04 at 05:06'));
|
|
158
|
+
assert.ok(!strings.testInfo.includes('2026-01-01'));
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('getDateTimeString describes a date', () => {
|
|
162
|
+
assert.equal(getDateTimeString(new Date('2026-03-04T05:06:07Z')), '2026-03-04 at 05:06');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('getDateTimeString describes an invalid date as unknown', () => {
|
|
166
|
+
assert.equal(getDateTimeString(new Date(NaN)), 'an unknown date at an unknown time');
|
|
155
167
|
});
|
|
156
168
|
|
|
157
169
|
test('getPageDataStrings derives the strings from the report object', async () => {
|
|
@@ -209,6 +221,73 @@ test('getPageFactsLines returns the about-page list lines for a report', async (
|
|
|
209
221
|
assert.ok(html.includes('<li>Issues: 2</li>'));
|
|
210
222
|
});
|
|
211
223
|
|
|
224
|
+
test('getPageFactsLines ends with a link to download the full report', async () => {
|
|
225
|
+
const {getReportData} = await import('./util.ts');
|
|
226
|
+
const report = await getFixtureReport('260101T0000', 'mix');
|
|
227
|
+
const lines = getPageFactsLines(getPageDataStrings(report), getReportData(report), '');
|
|
228
|
+
const topItems = parse(lines.join('\n')).querySelectorAll('ul:not(ul ul) > li');
|
|
229
|
+
const lastItem = topItems[topItems.length - 1]!;
|
|
230
|
+
assert.ok(topItems[topItems.length - 2]!.text.trim().startsWith('Summary of results:'));
|
|
231
|
+
assert.equal(lastItem.querySelector('a')?.getAttribute('href'), '/fullReport.json/260101T0000/mix');
|
|
232
|
+
assert.equal(lastItem.text.trim(), 'Download the full technical report (JSON)');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// Returns the 3 rule-engine amounts and the 3 summary amounts of the page facts of a report.
|
|
236
|
+
const getResultFacts = async (timeStamp: string, jobID: string) => {
|
|
237
|
+
const {getReportData} = await import('./util.ts');
|
|
238
|
+
const report = await getFixtureReport(timeStamp, jobID);
|
|
239
|
+
const lines = getPageFactsLines(getPageDataStrings(report), getReportData(report), '');
|
|
240
|
+
const items = parse(lines.join('\n')).querySelectorAll('li');
|
|
241
|
+
const itemTexts: string[] = items.map(li => li.text.trim());
|
|
242
|
+
const getAmount = (label: string) => {
|
|
243
|
+
const text = itemTexts.find(t => t.startsWith(`${label}: `));
|
|
244
|
+
assert.ok(text, `${label} item exists`);
|
|
245
|
+
return Number(text.slice(label.length + 2));
|
|
246
|
+
};
|
|
247
|
+
// The summary item is the parent of the rule-engine item and the 3 amount items.
|
|
248
|
+
const summaryItem = items.find(li => li.text.trim().startsWith('Summary of results:'));
|
|
249
|
+
assert.ok(summaryItem, 'Summary of results item exists');
|
|
250
|
+
const summaryList = summaryItem.querySelector('ul')!;
|
|
251
|
+
const amountLabels = summaryList.childNodes
|
|
252
|
+
.filter((node: any) => node.tagName === 'LI')
|
|
253
|
+
.map(li => li.text.trim().split(':')[0]);
|
|
254
|
+
return {
|
|
255
|
+
called: getAmount('Called'),
|
|
256
|
+
tested: getAmount('Were able to test'),
|
|
257
|
+
reporters: getAmount('Reported any rule violations'),
|
|
258
|
+
amountLabels,
|
|
259
|
+
violations: getAmount('Violations'),
|
|
260
|
+
violators: getAmount('Violators'),
|
|
261
|
+
issues: getAmount('Issues')
|
|
262
|
+
};
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
// Expected facts, hand-computed from the fixtures.
|
|
266
|
+
const expectedFacts: [string, string, number, number, number, number, number, number][] = [
|
|
267
|
+
// Time stamp, job ID, engines called, engines able to test, reporting engines, violations, violators, issues.
|
|
268
|
+
['260101T0000', 'mix', 2, 2, 2, 3, 2, 2],
|
|
269
|
+
['260202T0000', 'new', 1, 1, 1, 1, 1, 1],
|
|
270
|
+
['260101T0001', 'ct', 1, 1, 0, 0, 0, 0],
|
|
271
|
+
['260101T0002', 'no', 1, 1, 1, 1, 1, 1],
|
|
272
|
+
['260101T0005', 'emp', 1, 1, 0, 0, 0, 0],
|
|
273
|
+
['260101T0006', 'prv', 2, 1, 1, 1, 1, 1],
|
|
274
|
+
['260101T0008', 'mul', 2, 2, 2, 4, 3, 1],
|
|
275
|
+
['260101T0009', 'brd', 3, 3, 1, 4, 4, 1]
|
|
276
|
+
];
|
|
277
|
+
|
|
278
|
+
for (const [timeStamp, jobID, called, tested, reporters, violations, violators, issues] of expectedFacts) {
|
|
279
|
+
test(`getPageFactsLines gives correct rule-engine and summary amounts for ${timeStamp}-${jobID}`, async () => {
|
|
280
|
+
const facts = await getResultFacts(timeStamp, jobID);
|
|
281
|
+
assert.equal(facts.called, called);
|
|
282
|
+
assert.equal(facts.tested, tested);
|
|
283
|
+
assert.equal(facts.reporters, reporters);
|
|
284
|
+
assert.deepEqual(facts.amountLabels, ['Rule engines', 'Violations', 'Violators', 'Issues']);
|
|
285
|
+
assert.equal(facts.violations, violations);
|
|
286
|
+
assert.equal(facts.violators, violators);
|
|
287
|
+
assert.equal(facts.issues, issues);
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
212
291
|
test('getIssueFactsLines returns the about-issue list lines for a report', async () => {
|
|
213
292
|
const {getIssueFactsLines} = await import('./util.ts');
|
|
214
293
|
const report = await getFixtureReport('260101T0000', 'mix');
|
|
@@ -288,21 +367,6 @@ test('getEngineNamesString falls back to the ID for an unknown engine', () => {
|
|
|
288
367
|
assert.equal(getEngineNamesString(new Set(['unknownEngine'])), 'unknownEngine');
|
|
289
368
|
});
|
|
290
369
|
|
|
291
|
-
test('getMultiReportWhats returns descriptions that have multiple reports', async () => {
|
|
292
|
-
const whats = await getMultiReportWhats();
|
|
293
|
-
assert.ok(whats.includes('Mixed Outcomes Page'));
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
test('getMultiReportWhats uses extracts provided by the caller', async () => {
|
|
297
|
-
const extract = {timeStamp: 't', jobID: 'j', url: 'https://example.com/', reportTime: ''};
|
|
298
|
-
const whats = await getMultiReportWhats([
|
|
299
|
-
{...extract, description: 'B'},
|
|
300
|
-
{...extract, description: 'A'},
|
|
301
|
-
{...extract, description: 'B'}
|
|
302
|
-
]);
|
|
303
|
-
assert.deepEqual(whats, ['B']);
|
|
304
|
-
});
|
|
305
|
-
|
|
306
370
|
// Returns a minimal report for testing rule-engine identification.
|
|
307
371
|
const makeEngineReport = (whiches: string[], preventions: Record<string, string>) => ({
|
|
308
372
|
acts: whiches.map(which => ({type: 'test', which})),
|