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(runtime): add preview.pathname #233

Merged
merged 5 commits into from
Aug 12, 2024
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
20 changes: 17 additions & 3 deletions docs/tutorialkit.dev/src/content/docs/reference/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -91,18 +91,32 @@ Configure whether or not the editor should be rendered. If an object is provided
##### `previews`
Configure which ports should be used for the previews allowing you to align the behavior with your demo application's dev server setup. If not specified, the lowest port will be used.

You can optionally provide these as an array of tuples where the first element is the port number and the second is the title of the preview, or as an object.
<PropertyTable inherited type={'Preview[]'} />

The `Preview` type has the following shape:

```ts
type Preview = string
type Preview =
| number
| string
| [port: number, title: string]
| { port: number, title: string }
| [port: number, title: string, pathname: string]
| { port: number, title: string, pathname?: string }

```

Example value:

```yaml
previews:
- 3000 # Preview is on :3000/
- "3001/docs" # Preview is on :3001/docs/
- [3002, "Dev Server"] # Preview is on :3002/. Displayed title is "Dev Server".
- [3003, "Dev Server", "/docs"] # Preview is on :3003/docs/. Displayed title is "Dev Server".
- { port: 3004, title: "Dev Server" } # Preview is on :3004/. Displayed title is "Dev Server".
- { port: 3005, title: "Dev Server", pathname: "/docs" } # Preview is on :3005/docs/. Displayed title is "Dev Server".
AriPerkkio marked this conversation as resolved.
Show resolved Hide resolved
```
##### `mainCommand`
The main command to be executed. This command will run after the `prepareCommands`.
<PropertyTable inherited type="Command" />
Expand Down
78 changes: 78 additions & 0 deletions packages/runtime/src/store/previews.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { assert, expect, test } from 'vitest';
import { PreviewsStore } from './previews.js';
import type { PortListener, WebContainer } from '@webcontainer/api';

test("preview is set ready on webcontainer's event", async () => {
const { store, emit } = await getStore();
store.setPreviews([3000]);

assert(store.previews.value);
expect(store.previews.value[0].ready).toBe(false);

emit(3000, 'open', 'https://localhost');

expect(store.previews.value![0].ready).toBe(true);
});

test('preview is not set ready when different port is ready', async () => {
const { store, emit } = await getStore();
store.setPreviews([3000]);

assert(store.previews.value);
expect(store.previews.value[0].ready).toBe(false);

emit(3001, 'open', 'https://localhost');

expect(store.previews.value[0].ready).toBe(false);
});

test('marks multiple preview infos ready', async () => {
const { store, emit } = await getStore();
store.setPreviews([
{ port: 3000, title: 'Dev' },
{ port: 3000, title: 'Docs', pathname: '/docs' },
]);

assert(store.previews.value);
expect(store.previews.value).toHaveLength(2);

expect(store.previews.value[0].ready).toBe(false);
expect(store.previews.value[0].pathname).toBe(undefined);

expect(store.previews.value[1].ready).toBe(false);
expect(store.previews.value[1].pathname).toBe('/docs');

emit(3000, 'open', 'https://localhost');

expect(store.previews.value[0].ready).toBe(true);
expect(store.previews.value[1].ready).toBe(true);
});

async function getStore() {
const listeners: PortListener[] = [];

const webcontainer: Pick<WebContainer, 'on'> = {
on: (type, listener) => {
if (type === 'port') {
listeners.push(listener as PortListener);
}

return () => undefined;
},
};

const promise = new Promise<WebContainer>((resolve) => {
resolve(webcontainer as WebContainer);
});

await promise;

return {
store: new PreviewsStore(promise),
emit: (...args: Parameters<PortListener>) => {
assert(listeners.length > 0, 'Port listeners were not captured');

listeners.forEach((cb) => cb(...args));
},
};
}
38 changes: 18 additions & 20 deletions packages/runtime/src/store/previews.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type { PreviewSchema } from '@tutorialkit/types';
import type { WebContainer } from '@webcontainer/api';
import { atom } from 'nanostores';
import { PreviewInfo } from '../webcontainer/preview-info.js';
import type { WebContainer } from '@webcontainer/api';
import { PortInfo } from '../webcontainer/port-info.js';

export class PreviewsStore {
private _availablePreviews = new Map<number, PreviewInfo>();
private _availablePreviews = new Map<number, PortInfo>();
private _previewsLayout: PreviewInfo[] = [];

/**
Expand All @@ -21,18 +22,19 @@ export class PreviewsStore {
const webcontainer = await webcontainerPromise;

webcontainer.on('port', (port, type, url) => {
let previewInfo = this._availablePreviews.get(port);
let portInfo = this._availablePreviews.get(port);

if (!portInfo) {
portInfo = new PortInfo(port, url, type === 'open');

if (!previewInfo) {
previewInfo = new PreviewInfo(port, type === 'open');
this._availablePreviews.set(port, previewInfo);
this._availablePreviews.set(port, portInfo);
}

previewInfo.ready = type === 'open';
previewInfo.baseUrl = url;
portInfo.ready = type === 'open';
portInfo.origin = url;

if (this._previewsLayout.length === 0) {
this.previews.set([previewInfo]);
AriPerkkio marked this conversation as resolved.
Show resolved Hide resolved
this.previews.set([new PreviewInfo({}, portInfo)]);
} else {
this._previewsLayout = [...this._previewsLayout];
this.previews.set(this._previewsLayout);
Expand All @@ -55,20 +57,16 @@ export class PreviewsStore {
// if the schema is `true`, we just use the default empty array
const previews = config === true ? [] : config ?? [];

const previewInfos = previews.map((preview) => {
const info = new PreviewInfo(preview);
const previewInfos = previews.map((previewConfig) => {
const preview = PreviewInfo.parse(previewConfig);
let portInfo = this._availablePreviews.get(preview.port);

let previewInfo = this._availablePreviews.get(info.port);

if (!previewInfo) {
previewInfo = info;

this._availablePreviews.set(previewInfo.port, previewInfo);
} else {
previewInfo.title = info.title;
AriPerkkio marked this conversation as resolved.
Show resolved Hide resolved
if (!portInfo) {
portInfo = new PortInfo(preview.port);
this._availablePreviews.set(preview.port, portInfo);
}

return previewInfo;
return new PreviewInfo(preview, portInfo);
});

let areDifferent = previewInfos.length != this._previewsLayout.length;
Expand Down
7 changes: 7 additions & 0 deletions packages/runtime/src/webcontainer/port-info.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export class PortInfo {
constructor(
readonly port: number,
public origin?: string,
public ready: boolean = false,
) {}
}
78 changes: 59 additions & 19 deletions packages/runtime/src/webcontainer/preview-info.spec.ts
Original file line number Diff line number Diff line change
@@ -1,80 +1,120 @@
import { describe, it, expect } from 'vitest';
import { PreviewInfo } from './preview-info.js';
import { PortInfo } from './port-info.js';

describe('PreviewInfo', () => {
it('should accept a port', () => {
const previewInfo = new PreviewInfo(3000);
it('should accept a number for port', () => {
const previewInfo = PreviewInfo.parse(3000);

expect(previewInfo.port).toBe(3000);
expect(previewInfo.title).toBe(undefined);
expect(previewInfo.pathname).toBe(undefined);
});

it('should accept a string for port and pathname', () => {
const previewInfo = PreviewInfo.parse('3000/some/nested/path');

expect(previewInfo.port).toBe(3000);
expect(previewInfo.pathname).toBe('some/nested/path');
expect(previewInfo.title).toBe(undefined);
});

it('should accept a tuple of [port, title]', () => {
const previewInfo = new PreviewInfo([3000, 'Local server']);
const previewInfo = PreviewInfo.parse([3000, 'Local server']);

expect(previewInfo.port).toBe(3000);
expect(previewInfo.title).toBe('Local server');
expect(previewInfo.pathname).toBe(undefined);
});

it('should accept a tuple of [port, title, pathname]', () => {
const previewInfo = PreviewInfo.parse([3000, 'Local server', '/docs']);

expect(previewInfo.port).toBe(3000);
expect(previewInfo.title).toBe('Local server');
expect(previewInfo.pathname).toBe('/docs');
});

it('should accept an object with { port, title }', () => {
const previewInfo = new PreviewInfo({ port: 3000, title: 'Local server' });
const previewInfo = PreviewInfo.parse({ port: 3000, title: 'Local server' });

expect(previewInfo.port).toBe(3000);
expect(previewInfo.title).toBe('Local server');
expect(previewInfo.pathname).toBe(undefined);
});

it('should accept an object with { port, title, pathname }', () => {
const previewInfo = PreviewInfo.parse({ port: 3000, title: 'Local server', pathname: '/docs' });

expect(previewInfo.port).toBe(3000);
expect(previewInfo.title).toBe('Local server');
expect(previewInfo.pathname).toBe('/docs');
});

it('should not be ready by default', () => {
const previewInfo = new PreviewInfo(3000);
const previewInfo = new PreviewInfo({}, new PortInfo(3000));

expect(previewInfo.ready).toBe(false);
});

it('should be ready if explicitly set', () => {
const previewInfo = new PreviewInfo(3000, true);
const previewInfo = new PreviewInfo({}, new PortInfo(3000, undefined, true));

expect(previewInfo.ready).toBe(true);
});

it('should not be ready if explicitly set', () => {
const previewInfo = new PreviewInfo(3000, false);
const previewInfo = new PreviewInfo({}, new PortInfo(3000, undefined, false));

expect(previewInfo.ready).toBe(false);
});

it('should have a url with a custom pathname and baseUrl', () => {
const previewInfo = new PreviewInfo(3000);
previewInfo.baseUrl = 'https://example.com';
previewInfo.pathname = '/foo';
const parsed = PreviewInfo.parse('3000/foo');
const previewInfo = new PreviewInfo(parsed, new PortInfo(parsed.port));
previewInfo.portInfo.origin = 'https://example.com';

expect(previewInfo.url).toBe('https://example.com/foo');
});

it('should be equal to another preview info with the same port and title', () => {
const a = new PreviewInfo(3000);
const b = new PreviewInfo(3000);
const a = new PreviewInfo({}, new PortInfo(3000));
const b = new PreviewInfo({}, new PortInfo(3000));

expect(PreviewInfo.equals(a, b)).toBe(true);
});

it('should not be equal to another preview info with a different port', () => {
const a = new PreviewInfo(3000);
const b = new PreviewInfo(4000);
const a = new PreviewInfo({}, new PortInfo(3000));
const b = new PreviewInfo({}, new PortInfo(4000));

expect(PreviewInfo.equals(a, b)).toBe(false);
});

it('should not be equal to another preview info with a different title', () => {
const a = new PreviewInfo([3000, 'Local server']);
const b = new PreviewInfo([3000, 'Remote server']);
const parsed = {
a: PreviewInfo.parse([3000, 'Local server']),
b: PreviewInfo.parse([3000, 'Remote server']),
};

const a = new PreviewInfo(parsed.a, new PortInfo(parsed.a.port));
const b = new PreviewInfo(parsed.b, new PortInfo(parsed.b.port));

expect(PreviewInfo.equals(a, b)).toBe(false);
});

it('should not be equal to another preview info with a different pathname', () => {
const a = new PreviewInfo(3000);
const b = new PreviewInfo(3000);
const parsed = {
a: PreviewInfo.parse(3000),
b: PreviewInfo.parse('3000/b'),
c: PreviewInfo.parse('3000/c'),
};

a.pathname = '/foo';
const a = new PreviewInfo(parsed.a, new PortInfo(parsed.a.port));
const b = new PreviewInfo(parsed.b, new PortInfo(parsed.b.port));
const c = new PreviewInfo(parsed.c, new PortInfo(parsed.c.port));

expect(PreviewInfo.equals(a, b)).toBe(false);
expect(PreviewInfo.equals(b, c)).toBe(false);
});
});
Loading