Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,5 @@ coverage/

# Secrets and environment-specific configurations
.env
.env-prod
.secrets
114 changes: 114 additions & 0 deletions api/awaitTest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/*
awaitTest.test.ts
Tests for api/awaitTest.ts using the fixture corpus.
*/

// IMPORTS

import {test, before, after} from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import {fixtureDBDir} from '../test/dbFixture.ts';

for (const key of ['MANAGER_EMAIL', 'ALERT_API_HOST', 'ALERT_API_PATH', 'ALERT_API_KEY', 'ALERT_FROM']) {
process.env[key] = '';
}
// Keep the poll fast in this file, since these tests do not exercise timing edge cases
// (those live in util.test.ts, closer to awaitJob itself); a short interval just keeps
// this file's own tests quick.
process.env.AWAIT_JOB_POLL_MS = '10';
process.env.AWAIT_JOB_TIMEOUT_MS = '50';

// SETUP AND TEARDOWN

const savedDBDir = process.env.DB_DIR;

before(() => {
process.env.DB_DIR = fixtureDBDir;
});

import {response} from './awaitTest.ts';

after(() => {
delete process.env.AWAIT_JOB_POLL_MS;
delete process.env.AWAIT_JOB_TIMEOUT_MS;
if (savedDBDir !== undefined) {
process.env.DB_DIR = savedDBDir;
}
else {
delete process.env.DB_DIR;
}
});

// TESTS

test('awaitTest rejects a malformed timestamp or job identifier', async () => {
const body = await response(['999999T9999', 'xyz']);
const details = body['response content']['details about your wait'] as any;
assert.ok(details.error.includes('malformed'));
assert.equal(body['response content']['disposition of your wait'], null);
});

test('awaitTest returns "completed" for an identifier with an already-available report', async () => {
const body = await response(['260101T0000', 'mix']);
const details = body['response content']['details about your wait'] as any;
assert.deepEqual(details['report identifier'], {timeStamp: '260101T0000', jobID: 'mix'});
const disposition = body['response content']['disposition of your wait'] as any;
assert.equal(disposition.outcome, 'completed');
assert.ok(disposition['how you can check for completion'].includes('listIssues'));
});

test('awaitTest returns an error for an identifier naming neither a report nor a pending job', async () => {
const body = await response(['260101T0000', 'zzz']);
const details = body['response content']['details about your wait'] as any;
assert.ok(details.error.includes('no report'));
assert.equal(body['response content']['disposition of your wait'], null);
});

test('awaitTest returns "failed" when the job has moved to the failed directory', async () => {
const failedPath = path.join(fixtureDBDir, 'jobs', 'failed', '260101T4444-flj.json');
await fs.mkdir(path.dirname(failedPath), {recursive: true});
await fs.writeFile(failedPath, JSON.stringify({target: {what: 'Failed Page', url: 'https://example.com/failed'}}));
try {
const body = await response(['260101T4444', 'flj']);
const disposition = body['response content']['disposition of your wait'] as any;
assert.equal(disposition.outcome, 'failed');
assert.equal(disposition['how you can check for completion'], 'Not applicable.');
}
finally {
await fs.unlink(failedPath);
}
});

test('awaitTest returns "timedOut" when a claimed job neither completes nor fails before the deadline', async () => {
const claimedPath = path.join(fixtureDBDir, 'jobs', 'claimed', '260101T5555-toj.json');
await fs.writeFile(claimedPath, JSON.stringify({target: {what: 'Stuck Page', url: 'https://example.com/stuck'}}));
try {
const body = await response(['260101T5555', 'toj']);
const disposition = body['response content']['disposition of your wait'] as any;
assert.equal(disposition.outcome, 'timedOut');
assert.ok(disposition['how you can check for completion'].includes('listIssues'));
}
finally {
await fs.unlink(claimedPath);
}
});

test('awaitTest response has no web UI equivalents for this request or its ancestors', async () => {
const body = await response(['260101T0000', 'mix']);
const similarWeb = body['URLs of similar requests for web users'] as any;
assert.equal(similarWeb['this request'], null);
assert.equal(similarWeb['closest ancestor request'], null);
});

test('awaitTest reports both orderTest and orderRetest as possible ancestors', async () => {
const body = await response(['260101T0000', 'mix']);
const ancestors = (body['this request'] as any)['closest ancestor request'];
assert.equal(ancestors.length, 2);
const names = ancestors.map((a: any) => a['tool name']).sort();
assert.deepEqual(names, ['orderRetest', 'orderTest']);
for (const ancestor of ancestors) {
assert.equal(ancestor.method, 'POST');
}
});
108 changes: 108 additions & 0 deletions api/awaitTest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*
awaitTest.ts
Waits for a job ordered via orderTest or orderRetest to complete, and returns its outcome.
*/

// IMPORTS

import {z} from 'zod';
import {getResponseMetadata, getThisHost, getToolsFacts} from './util.ts';
import {awaitJob, isJobID, isTimeStamp} from '../util.ts';
import {awaitTestResponseSchema} from './schemas.ts';

// TYPES

// The response content defined by the response schema.
type ResponseContent = z.infer<typeof awaitTestResponseSchema>['response content'];

// FUNCTIONS

// Returns the response body.
export const response = async (args: string[]) => {
const [timeStamp = '', jobID = ''] = args;
const thisHost = getThisHost();
// Initialize the response-content properties.
let requestDetails: ResponseContent['details about your wait'];
let requestDisposition: ResponseContent['disposition of your wait'] = null;
// If the timestamp or job ID is not syntactically valid:
if (!isTimeStamp(timeStamp) || !isJobID(jobID)) {
requestDetails = {
error: 'wait invalid: the report timeStamp or jobID is malformed'
};
}
// Otherwise, i.e. if the identifier is syntactically valid:
else {
requestDetails = {
'report identifier': {timeStamp, jobID}
};
// Wait for the job to complete, fail, or time out. No progress notices are sent
// during this wait; the caller receives only this one, final response.
const outcome = await awaitJob(timeStamp, jobID);
if (outcome === 'notFound') {
requestDetails = {
error: `wait invalid: no report with timeStamp "${timeStamp}" and jobID "${jobID}" is completed, pending, or in progress`
};
}
else if (outcome === 'completed') {
requestDisposition = {
outcome: 'completed',
'what happened': 'The report is ready.',
'how you can check for completion': `Call the listIssues tool with timeStamp "${timeStamp}" and jobID "${jobID}" to retrieve the report.`
};
}
else if (outcome === 'failed') {
requestDisposition = {
outcome: 'failed',
'what happened': 'The job failed, so no report will be produced.',
'how you can check for completion': 'Not applicable.'
};
}
else {
requestDisposition = {
outcome: 'timedOut',
'what happened': 'The job was not completed within the maximum wait time, but it may still be running.',
'how you can check for completion': `Call the listIssues tool with timeStamp "${timeStamp}" and jobID "${jobID}" after a few minutes to check whether the report is ready.`
};
}
}
// Create the response content.
const responseContent: ResponseContent = {
'details about your wait': requestDetails,
'disposition of your wait': requestDisposition
};
// Create a response body.
const body = {
'tool collection': getToolsFacts(),
'tool name': 'awaitTest',
'this request': {
description: 'Wait for a job ordered via the orderTest or orderRetest tool to complete. The timeStamp and jobID parameters are the report identifier that orderTest or orderRetest returned. This call blocks until the report is ready, the job fails, or a maximum wait time elapses.',
method: 'POST',
URL: `${thisHost}/api/awaitTest/${timeStamp}/${jobID}`,
// awaitTest's own parameters do not say whether the job was ordered via orderTest
// or orderRetest, so both are reported as possible ancestors, rather than
// arbitrarily naming just one.
'closest ancestor request': [
{
'tool name': 'orderTest',
description: 'Fulfill or reject my order to test a page about which no report is available yet.',
method: 'POST',
URL: `${thisHost}/api/orderTest`
},
{
'tool name': 'orderRetest',
description: 'Fulfill or reject my order to retest a page.',
method: 'POST',
URL: `${thisHost}/api/orderRetest`
}
]
},
'URLs of similar requests for web users': {
'this request': null,
'closest ancestor request': null
},
'response metadata': getResponseMetadata(),
'response content': responseContent
};
// Return it.
return body;
};
Loading
Loading