diff --git a/packages/holodeck/docs/holo-programs.md b/packages/holodeck/docs/holo-programs.md new file mode 100644 index 0000000000..0c7a5ebe6d --- /dev/null +++ b/packages/holodeck/docs/holo-programs.md @@ -0,0 +1,306 @@ +# HoloPrograms + +
+ +**HoloPrograms** are sets of simulated API interactions that can be used to quickly +set the scene for a test. + +
+ +**Table of Contents** + +- Using HoloPrograms + - [Using a HoloProgram](#using-a-holoprogram) + - [Adjusting a HoloProgram from Within a Test](#adjusting-a-holoprogram-from-within-a-test) + - [HoloProgam Replay](#holoprogram-replay) +- Creating HoloPrograms + - [Creating a HoloProgram](#creating-a-holoprogram) + - [Route Handlers](#1-shared-route-handlers) + - [Seed Data](#2-seed-data) + - [Easy Mode for Building JSON for Seeds](#easy-mode-for-building-up-json-for-seeds) + - [Defining HoloProgram Behaviors](#3-holoprogram-specific-behaviors) + - [Available Behaviors](#available-behaviors) + +
+ +--- + +
+ +### Using a HoloProgram + +A test declares upfront what HoloProgram it is using. + +```ts +import { module, test } from 'qunit'; +import { startProgram, POST } from '@warp-drive/holodeck'; + +module('First Contact', function() { + test('borg are vulnerable to holographic bullets', async function(assert) { + await startProgram(this, 'the-big-goodbye_chapter-13'); + + // now all requests made in this test will be resolved using the program + // defined as 'the-big-goodbye_chapter-13' + + }); +}); +``` + +
+ +### Adjusting a HoloProgram from Within a Test + +HoloPrograms can be adjusted throughout a test if required. For instance, to add handling for a request that wasn't in the original program, or to provide a different response to the next request. + +```ts +import { module, test } from 'qunit'; +import { startProgram , POST } from '@warp-drive/holodeck'; + +module('First Contact', function() { + test('borg are vulnerable to holographic bullets', async function(assert) { + await startProgram(this, 'the-big-goodbye_chapter-13'); + + // now the next POST request to `/casualty` will respond with this payload + // note: this will cause this particular request to NOT update any HoloProgram + // state as it will no longer be handled by the Program + await POST(this, '/casualty', () => ({ + data: { + id: '3', + type: 'casualty', + attributes: { + species: 'human', + affiliation: 'borg', + name: 'Ensign Lynch', + }, + }, + }); + + }); +}); +``` + +To update the state of a HoloProgram from within the test instead, we can use `updateProgram` + +```ts +import { module, test } from 'qunit'; +import { startProgram, updateProgram } from '@warp-drive/holodeck'; + +module('First Contact', function() { + test('borg are vulnerable to holographic bullets', async function(assert) { + await startProgram(this, 'The Big Boodbye | Chapter 13'); + + // the payload provided here will be upserted directly into the program + // cache, and thus should match the cache format in use. + // updates to a program from within a test will not affect any other tests + await updateProgram(this, () => ({ + data: { + id: '3', + type: 'casualty', + attributes: { + species: 'human', + affiliation: 'borg', + name: 'Ensign Lynch', + }, + }, + }); + + // any requests from here out that return `casualty:3` + // will have the updated data + + }); +}); +``` + +
+ +### HoloProgram Replay + +HoloPrograms record all requests for replay the same as any other mocked request, +thus the program does not activate in replay mode. + +
+ +--- + +
+ +## Creating a HoloProgram + +Every HoloProgram consists of three things: route handlers, a seed, and preset behaviors. + +
+ +### 1. Shared Route Handlers + +In holodeck, all HoloPrograms utilize the same underlying route handlers. This encourages writing realistic handlers which in turn makes authoring new tests faster and easier. + +Route Handlers are responsible for parsing a request and providing a response. This typically takes the form of querying (and updating) the HoloProgram's store to match the request's intent. + +Note: any updates made to the store affect future requests within the same test, making it easy to generate realistic API scenarios. + +```ts +import { Router } from '@warp-drive/holodeck'; + +// The holodeck router encourages lazy-evaluation as a pattern +// in order to ensure the server boots and begins responding to +// request as quickly as possible +// +// the router map is only generated if holodeck needs to record +export default new Router((r) => { + r.GET('/officers', async () => { + // the cost of importing and parsing the handler code is only paid if + // a matching request is made. + return (await import('./handlers/officers')).GET; + }) +}); +``` + +
+ +### 2. Seed Data + +While all tests share the same route handlers, each HoloProgram begins from a unique store state. + +The store (and its starting state) are encapsulated to the test context and will never leak between tests, even when tests are recording concurrently. + +The starting seed data should be an array of json resources in the configured cache format, which can be generated via any mechanism desired. + +```ts +import { createProgram } from '@warp-drive/holodeck'; +import { fnThatGeneratesJson } from './my-seed'; + +await createProgram({ + name: 'The Big Goodbye | Chapter 13', + seed: fnThatGeneratesJson, +}); +``` + +In keeping with the encouraged pattern of lazy evaluation, the seed function only executes if the program needs to be booted for a test in record mode. + +
+ +### Easy Mode for Building up JSON for Seeds + +Don't know or understand the cache format? No sweat! + +We can use a store instance to generate the data using the record types and API's we are familiar with from our app, and then serialize this to a seed. + +In general, this allows us to write fairly composable functions to build up our seed quickly. + +For instance: + +```ts +import { serializeCache } from '@warp-drive/holodeck'; +import Store from 'my-enterprise/services/store'; +import type { Officer, Starship } from 'my-enterprise/schema-types'; + +function generateOfficers(store: Store) { + const Picard = store.createRecord('officer', { + id: '1', + name: 'Jean-Luc Picard', + rank: 'Captain' + }); + + const Riker = store.createRecord('officer', { + id: '2', + name: 'William Thomas Riker', + rank: 'First-Officer', + bestFriend: Picard; + }); + + return [Picard, Riker]; +} + +function generateStarship(store: Store, crew: Officer[]) { + const Enterprise = store.createRecord('starship', { + id: 'NCC-1701-D' + name: 'Enterprise', + crew, + }); + + return Enterprise; +} + +export function generateSeed() { + const store = new Store(); + + const officers = generateOfficers(store); + generateStarship(store, officers); + + return serializeCache(store); +} +``` + +A key feature to be aware of is that because all resources MUST have a primaryKey value, `serializeCache` will assign a uuid-v4 as the primaryKey value for any record you have not assigned one to. + +
+ +### 3. HoloProgram Specific Behaviors + +Most HoloPrograms will only ever require a seed to go along with the defined route handlers. But sometimes you may want a holoprogram to simulate externalities. + +Externalities are things like "the API state updated in between the time a user made their last request and their next one" or "this endpoint should have a delay or timeout". + +To handle these sorts of scenarios, HoloPrograms can augment the defined handlers for a specific route: + +```ts +import { createProgram } from '@warp-drive/holodeck'; +import { generateSeed } from './my-seed'; + +await createProgram({ + name: 'The Big Goodbye | Chapter 13', + seed: generateSeed, + behaviors: (r) => { + // passing an object as the second param will apply the adjustment to the route + // on every request + r.GET('/starships', { delay: 50 }); + + // when we pass an array of objects, each object is an adjustment + // for a single request. + // + // The first request to /officers will have a 20ms delay + // The second request to /officers will have a 100ms delay + // The third request to /officers will have no delay + r.GET('/officers', [{ delay: 20 }, { delay: 100 }]); + } +}) +``` + +
+ +### Available Behaviors + +The following behaviors are available: + +```ts +type Adjustment = { + // milliseconds to wait before either invoking the registered + // handler or responding with an error augmentation + requestDelay?: number; + + // milliseconds to wait after invoking the registered handler + // or preparing an error augmentation before sending the response + // back to the client + responseDelay?: number; + + // an error to respond with instead of the handler's usual behavior + // see also the statusCode utils + // the usual handler WILL NOT run + error?: { + status: number; // >= 400; + statusText?: string; // will be autopopulated if not provided based on statusCode + body?: string; + headers?: Headers | Record; + } + + // update store state only after this request + // has completed sending its response + after?: (request: Request, store: Store) => {} +} +``` + +
+ +--- + +