Skip to content
Open
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
6 changes: 6 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ jobs:
- name: app:sync tests
run: npm run test:scripts

- name: Job types are up to date with schemas/
run: npm run schemas -- --check

- name: Generated /v1 client is up to date with its OpenAPI snapshot
run: npm run openapi:v1 && git diff --exit-code -- src/app/api/v1

- name: Unit tests
run: npx ng test --watch=false --browsers=ChromeHeadless

Expand Down
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,26 @@ driven by `environment` and `prodUrl` in the chart's runtime config.
The family selects the header branding (`src/app/family-branding.ts`) and the
deployment conventions (prod domain, namespace). Everything else is shared.

## The job flow (`/v1`)

The app submits to mmli-backend's versioned API: the form (`/test`) posts the tool's
input document and any files as multipart, Loading shows the job queued and then
processing, and the results page is `/result/:id`, the route the backend's
notification email links to.

- **API client:** `src/app/api/v1`, generated from the snapshot in
`openapi/mmli-backend-v1.openapi.json`. `npm run openapi:v1:sync` refreshes the
snapshot from staging and regenerates; `npm run openapi:v1` only regenerates. It uses
openapi-generator 7.24.0 (`openapitools.v1.json`), because 6.6.0, which the legacy
client in `src/app/api/mmli-backend` uses, reads FastAPI's OpenAPI 3.1 parameters as
`any`.
- **Job input and result types:** `src/app/api/tool-schemas.ts` (`ToolInput`,
`ToolResults`), generated from `schemas/input.schema.json` and
`schemas/output.schema.json`. `npm run schemas:sync` fetches both for `app.yaml`'s
`job_type` from the staging backend and regenerates; `npm run schemas` only
regenerates. CI runs `npm run schemas -- --check`. The template's schemas are the
sample tool's, from [job-template](https://github.com/moleculemaker/job-template).

## Components

All builtin components are inside the `src/app/components` folder.
Expand All @@ -63,7 +83,11 @@ Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.

## Running end-to-end tests

Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities.
Start the app (`npm start`), then run `npx nightwatch ./nightwatch --env chrome`. The
job-flow test runs against a mock of the `/v1` API that it starts itself, and points the
app at it by answering the `envvars.json` request over the Chrome DevTools Protocol, so
it needs Chrome and a chromedriver that matches it (`CHROMEWEBDRIVER` is the directory
containing one).

## Further help

Expand Down
6 changes: 4 additions & 2 deletions app.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ family: alphasynthesis
# own; -1 until it is created.
matomo_site_id: -1

# The mmli-backend job type this app submits, once registered there.
job_type: null
# The mmli-backend job type this app submits, once registered there. Its input and
# output schemas generate the app's job types (`npm run schemas:sync`). The template's
# is the job-template sample tool, which the backend has not registered yet.
job_type: job-template-sample

# The compute image the backend runs for this app (ghcr.io/<org>/<tool>-job:<tag>).
compute_image: null
Expand Down
122 changes: 122 additions & 0 deletions nightwatch/src/job_flow_spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { createServer, IncomingMessage, Server } from 'node:http';
import { AddressInfo } from 'node:net';
import { NightwatchTests } from 'nightwatch';

/**
* Submit -> queued -> processing -> results, against a mock of mmli-backend's /v1 API.
*
* The app reads the API's address from /assets/config/envvars.json at startup; the
* test answers that request (over CDP, so Chrome only) with one pointing at the mock.
* The mock reports the job queued, then processing, then completed, one status per
* poll. The app polls every 10s, so this test takes about half a minute.
*/
const TESTING_URL = process.env['TESTING_URL'] || 'http://localhost:4200';
const JOB_ID = 'e2e-job';
const STATUSES = ['queued', 'processing', 'completed'];
const RESULTS = {
rows: [{ id: 'insulin-b', length: 30, molecular_weight_da: 3429.96 }],
artifacts: [{ name: 'results.csv', media_type: 'text/csv', description: 'All rows as CSV' }],
};

let server: Server;
let api = '';
let polls = 0;
let submitted: { tool: string; body: string; contentType: string; email?: string } | undefined;

const job = (status: string) => ({
job_id: JOB_ID, tool: 'job-template-sample', status, parent_job_id: null,
submitted_at: new Date().toISOString(), started_at: null, finished_at: null,
inputs: { sequences: [{ id: 'insulin-b', sequence: 'FVNQHLCGSHLVEALYLVCGERGFFYTPKT' }] },
});

function read(request: IncomingMessage): Promise<string> {
return new Promise((resolve) => {
let body = '';
request.on('data', (chunk) => (body += chunk));
request.on('end', () => resolve(body));
});
}

/** Waits in the test process, where the mock API records what it received. */
async function until(condition: () => boolean, timeoutMs: number): Promise<void> {
for (let waited = 0; !condition() && waited < timeoutMs; waited += 100) {
await browser.pause(100);
}
}

function startMockApi(): Promise<void> {
server = createServer(async (request, response) => {
response.setHeader('Access-Control-Allow-Origin', '*');
response.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Notify-Email');
if (request.method === 'OPTIONS') return response.writeHead(204).end();

const send = (status: number, body: unknown) =>
response.writeHead(status, { 'Content-Type': 'application/json' }).end(JSON.stringify(body));
const url = request.url ?? '';
const submit = url.match(/^\/v1\/tools\/([^/]+)\/jobs$/);

if (request.method === 'POST' && submit) {
submitted = {
tool: submit[1],
body: await read(request),
contentType: request.headers['content-type'] ?? '',
email: request.headers['x-notify-email'] as string | undefined,
};
return send(201, job('queued'));
}
if (url === `/v1/jobs/${JOB_ID}`) return send(200, job(STATUSES[Math.min(polls++, STATUSES.length - 1)]));
if (url === `/v1/jobs/${JOB_ID}/results`) return send(200, { job_id: JOB_ID, tool: 'job-template-sample', results: RESULTS });
send(404, { type: 'about:blank', title: 'Not found', status: 404 });
});
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => {
api = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
resolve();
}));
}

const jobFlow: NightwatchTests = {
before: async () => {
await startMockApi();
await browser.network.mockResponse(`${TESTING_URL}/assets/config/envvars.json`, {
status: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
hostname: api, basePath: '', signInUrl: '', signOutUrl: '', userInfoUrl: `${api}/userinfo`,
frontendOnly: 'false', environment: 'prod', prodUrl: '',
}),
});
},

after: () => {
server?.close();
},

'submits the form as multipart with the email in a header': async () => {
await browser.url(`${TESTING_URL}/test`);
await browser.waitForElementVisible('[data-testid="sequences"]');
await browser.setValue('[data-testid="sequences"]', '>insulin-b\nFVNQHLCGSHLVEALYLVCGERGFFYTPKT');
await browser.setValue('#subscriber-email', 'e2e@example.org');
await browser.click('[data-testid="submit"] button');

await until(() => submitted !== undefined, 5000);
browser.assert.ok(submitted !== undefined, 'the job was submitted');
browser.assert.equal(submitted!.tool, 'job-template-sample');
browser.assert.ok(submitted!.contentType.startsWith('multipart/form-data'), 'sent as multipart');
browser.assert.ok(submitted!.body.includes('"sequence":"FVNQHLCGSHLVEALYLVCGERGFFYTPKT"'), 'inputs part holds the document');
browser.assert.equal(submitted!.email, 'e2e@example.org');
},

'lands on /result/:id and shows queued, then processing': async () => {
await browser.assert.urlContains(`/result/${JOB_ID}`);
await browser.waitForElementVisible('[data-testid="job-queued"]', 5000);
await browser.waitForElementVisible('[data-testid="job-processing"]', 15000);
},

'shows the results once the job completes': async () => {
await browser.waitForElementVisible('[data-testid="results-table"]', 15000);
browser.assert.textContains('[data-testid="results-table"]', 'insulin-b');
browser.assert.textContains('[data-testid="results-table"]', '3429.96');
},
};

export default jobFlow;
2 changes: 1 addition & 1 deletion nightwatch/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"outDir": "../out-tsc/nightwatch",
"module": "commonjs",
"moduleResolution": "node",
"target": "es5",
"target": "es2017",
"types": [
"node",
"nightwatch"
Expand Down
Loading
Loading