Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: docs laying out the vision for holodeck #9616

Draft
wants to merge 8 commits into
base: main
Choose a base branch
from
Draft
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
212 changes: 25 additions & 187 deletions packages/holodeck/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,31 @@
- 🔥 Blazing Fast Tests
- record your tests when you change them
- replays from cache until you change them again
- the cache is managed by git, so changing branches works seamlessly as does skipping unneeded work in CI or while rebasing
- zero-work: setup work is skipped when in replay mode

## Installation
<br>

## Documentation

- [Motivations](./docs/motivations.md)
- [Server Setup](./docs/server-setup.md)
- [Client Setup](./docs/request-integration.md)
- [Test Framework Integration](./docs/test-framework-integration.md)
- [HoloPrograms](./docs/holo-programs.md)
- [Route Handlers](./docs/holo-programs.md#1-shared-route-handlers)
- [Seed Data](./docs/holo-programs.md#2-seed-data)
- [Behaviors](./docs/holo-programs.md#3-holoprogram-specific-behaviors)
- [Safety Protocols](./docs/holo-programs.md#safety-protocols)
- [VCR Style Tests](./docs/vcr-style.md)

<br>

---

<br>

## Installation

```json
pnpm install @warp-drive/holodeck
Expand All @@ -53,192 +74,9 @@ pnpm install @warp-drive/holodeck
- ![NPM LTS Version](https://img.shields.io/npm/v/%40warp-drive/holodeck/lts?label=%40lts&color=0096FF)
- ![NPM LTS 4.12 Version](https://img.shields.io/npm/v/%40warp-drive/holodeck/lts-4-12?label=%40lts-4-12&color=bbbbbb)



## Usage
#### Mocking from Within a Test

```ts
import { GET } from '@warp-drive/holodeck/mock';

await GET(context, 'users/1', () => ({
data: {
id: '1',
type: 'user',
attributes: {
name: 'Chris Thoburn',
},
},

// set RECORD to false or remove
// the options hash entirely once the request
// has been recorded
}), { RECORD: true });
```

## Motivations

Comprehensive DX around data management should extend to testing.

### ✨ Amazing Developer Experience

EmberData already understands your data schemas. Building a mocking utility with tight integration into your data usage patterns could bring enormous DX and test suite performance benefits.

Building a real mock server instead of intercepting requests in the browser or via ServiceWorker gives us out-of-the-box DX, better tunability, and greater ability to optimize test suite performance. Speed is the ultimate DX.

### 🔥 Blazing Fast Tests

We've noticed test suites spending an enormous amount of time creating and tearing down mock state in between tests. To combat this, we want to provide
an approach built over `http/3` (`http/2` for now) utilizing aggressive caching
and `brotli` minification in a way that can be replayed over and over again.

Basically, pay the cost when you write the test. Forever after skip the cost until you need to edit the test again.

## Setup

### Use with WarpDrive

First, you will need to add the holodeck handler to the request manager chain prior to `Fetch` (or any equivalent handler that proceeds to network).

For instance:

```ts
import RequestManager from '@ember-data/request';
import Fetch from '@ember-data/request/fetch';
import { MockServerHandler } from '@warp-drive/holodeck';

const manager = new RequestManager();
manager.use([new MockServerHandler(testContext), Fetch]);
```

From within a test this might look like:

```ts
import RequestManager from '@ember-data/request';
import Fetch from '@ember-data/request/fetch';
import { MockServerHandler } from '@warp-drive/holodeck';
import { module, test } from 'qunit';

module('my module', function() {
test('my test', async function() {
const manager = new RequestManager();
manager.use([new MockServerHandler(this), Fetch]);
});
});
```

Next, you will need to configure holodeck to understand your tests contexts. For qunit and diagnostic
in a project using Ember this is typically done in `tests/test-helper.js`

#### With Diagnostic

```ts
import { setupGlobalHooks } from '@warp-drive/diagnostic';
import { setConfig, setTestId } from '@warp-drive/holodeck';

// if not proxying the port / set port to the correct value here
const MockHost = `https://${window.location.hostname}:${Number(window.location.port) + 1}`;

setConfig({ host: MockHost });

setupGlobalHooks((hooks) => {
hooks.beforeEach(function (assert) {
setTestId(this, assert.test.testId);
});
hooks.afterEach(function () {
setTestId(this, null);
});
});
```

#### With QUnit

```ts
import * as QUnit from 'qunit';
import { setConfig, setTestId } from '@warp-drive/holodeck';

// if not proxying the port / set port to the correct value here
const MockHost = `https://${window.location.hostname}:${Number(window.location.port) + 1}`;

setConfig({ host: MockHost });

QUnit.hooks.beforeEach(function (assert) {
setTestId(assert.test.testId);
});
QUnit.hooks.afterEach(function (assert) {
setTestId(null);
});
```

### Testem

You can integrate holodeck with Testem using testem's [async config capability](https://github.com/testem/testem/blob/master/docs/config_file.md#returning-a-promise-from-testemjs):

```ts
module.exports = async function () {
const holodeck = (await import('@warp-drive/holodeck')).default;
await holodeck.launchProgram({
port: 7373,
});

process.on('beforeExit', async () => {
await holodeck.endProgram();
});

return {
// ... testem config
};
};
```

If you need the API mock to run on the same port as the test suite, you can use Testem's [API Proxy](https://github.com/testem/testem/tree/master?tab=readme-ov-file#api-proxy)

```ts
module.exports = async function () {
const holodeck = (await import('@warp-drive/holodeck')).default;
await holodeck.launchProgram({
port: 7373,
});

process.on('beforeExit', async () => {
await holodeck.endProgram();
});

return {
"proxies": {
"/api": {
// holodeck always runs on https
// the proxy is transparent so this means /api/v1 will route to https://localhost:7373/api/v1
"target": "https://localhost:7373",
// "onlyContentTypes": ["xml", "json"],
// if test suite is on http, set this to false
// "secure": false,
},
}
};
};
```

### Diagnostic

holodeck can be launched and cleaned up using the lifecycle hooks in the launch config
for diagnostic in `diagnostic.js`:

```ts
import launch from '@warp-drive/diagnostic/server/default-setup.js';
import holodeck from '@warp-drive/holodeck';

await launch({
async setup(options) {
await holodeck.launchProgram({
port: options.port + 1,
});
},
async cleanup() {
await holodeck.endProgram();
},
});
```
<br>
<br>
<br>

### ♥️ Credits

Expand Down
35 changes: 35 additions & 0 deletions packages/holodeck/client/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
let IS_RECORDING = false;
export function setIsRecording(value: boolean) {
IS_RECORDING = Boolean(value);
}
export function getIsRecording() {
return IS_RECORDING;
}

export type TestInfo = { id: string; request: number; mock: number };
const TEST_IDS = new WeakMap<object, { id: string; request: number; mock: number }>();

let HOST = 'https://localhost:1135/';
export function setConfig({ host }: { host: string }) {
HOST = host.endsWith('/') ? host : `${host}/`;
}

export function getConfig(): { host: string } {
return { host: HOST };
}

export function setTestId(context: object, str: string | null) {
if (str && TEST_IDS.has(context)) {
throw new Error(`The Holodeck mock handler is already configured with a testId.`);
}
if (str) {
TEST_IDS.set(context, { id: str, request: 0, mock: 0 });
} else {
TEST_IDS.delete(context);
}
}

export function getTestInfo(context: object): TestInfo | null {
const test = TEST_IDS.get(context);
return test ?? null;
}
40 changes: 40 additions & 0 deletions packages/holodeck/client/handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { Handler, NextFn, RequestContext, RequestInfo, StructuredDataDocument } from '@ember-data/request';

import { getTestInfo } from './env';

export class HolodeckHandler implements Handler {
declare owner: object;
constructor(owner: object) {
this.owner = owner;
}

async request<T>(context: RequestContext, next: NextFn<T>): Promise<StructuredDataDocument<T>> {
const test = getTestInfo(this.owner);
if (!test) {
throw new Error(`HolodeckHandler is not configured with a testId. Use setTestId to set the testId for each test`);
}

const request: RequestInfo = Object.assign({}, context.request);
const isRecording = request.url!.endsWith('/__record');
const firstChar = request.url!.includes('?') ? '&' : '?';
const queryForTest = `${firstChar}__xTestId=${test.id}&__xTestRequestNumber=${
isRecording ? test.mock++ : test.request++
}`;
request.url = request.url + queryForTest;

request.mode = 'cors';
request.credentials = 'omit';
request.referrerPolicy = '';

try {
const future = next(request);
context.setStream(future.getStream());
return await future;
} catch (e) {
if (e instanceof Error && !(e instanceof DOMException)) {
e.message = e.message.replace(queryForTest, '');
}
throw e;
}
}
}
4 changes: 4 additions & 0 deletions packages/holodeck/client/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { GET, PATCH, POST, PUT, DELETE, QUERY } from './macros';
export { setTestId } from './env';
export { mock } from './mock';
export { HolodeckHandler } from './handler';
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getIsRecording, mock } from '.';
import { getIsRecording } from './env';
import { mock } from './mock';

export interface Scaffold {
status: number;
Expand Down
23 changes: 23 additions & 0 deletions packages/holodeck/client/mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { getConfig, getIsRecording, getTestInfo } from './env';
import type { ScaffoldGenerator } from './macros';

export async function mock(owner: object, generate: ScaffoldGenerator, isRecording?: boolean) {
const config = getConfig();

const test = getTestInfo(owner);
if (!test) {
throw new Error(`Cannot call "mock" before configuring a testId. Use setTestId to set the testId for each test`);
}
const testMockNum = test.mock++;
if (getIsRecording() || isRecording) {
const port = window.location.port ? `:${window.location.port}` : '';
const url = `${config.host}__record?__xTestId=${test.id}&__xTestRequestNumber=${testMockNum}`;
await fetch(url, {
method: 'POST',
body: JSON.stringify(generate()),
mode: 'cors',
credentials: 'omit',
referrerPolicy: '',
});
}
}
54 changes: 54 additions & 0 deletions packages/holodeck/client/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
{
"include": ["./**/*"],
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"skipLibCheck": true,
"emitDeclarationOnly": true,
"allowJs": false,
"checkJs": false,
"alwaysStrict": true,
"strict": true,
"pretty": true,
"exactOptionalPropertyTypes": false,
"allowSyntheticDefaultImports": true,
"noImplicitAny": true,
"noImplicitThis": true,
"strictBindCallApply": true,
"strictFunctionTypes": true,
"strictPropertyInitialization": true,
"allowUnreachableCode": false,
"allowUnusedLabels": false,
"noEmitOnError": false,
"strictNullChecks": true,
"noErrorTruncation": true,
"preserveConstEnums": false,
"experimentalDecorators": true,
"composite": true,
"incremental": true,
"rootDir": ".",
// Support generation of source maps. Note: you must *also* enable source
// maps in your `ember-cli-babel` config and/or `babel.config.js`.
"declaration": true,
"declarationMap": true,
"declarationDir": "../unstable-preview-types/client",
"inlineSourceMap": true,
"inlineSources": true,
"baseUrl": ".",
"paths": {
"@ember-data/request": ["../../request/unstable-preview-types"],
"@ember-data/request/*": ["../../request/unstable-preview-types/*"],
"@warp-drive/core-types": ["../../core-types/unstable-preview-types"],
"@warp-drive/core-types/*": ["../../core-types/unstable-preview-types/*"]
}
},
"references": [
{
"path": "../../request"
},
{
"path": "../../core-types"
}
]
}
Loading
Loading