Skip to content

chore: add tests to runtime #75

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

Merged
merged 10 commits into from
Jun 19, 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,5 @@ dist-ssr
.pnpm-store
/tutorialkit/template
tsconfig.tsbuildinfo
tsconfig.build.tsbuildinfo
.tmp
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pnpm-lock.yaml
.astro
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't .astro files be gitignored?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The .astro folder is ignored already, I noticed that prettier was giving a warning locally because it doesn't ignore it.

It doesn't produce that warning on CI because we run prettier before running the build script. If the order was reversed, the CI would fail which would be a bit silly.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OOh weird. Ok, then it's fine.

**/*.md
**/*.mdx
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"template:build": "pnpm run build && pnpm run --filter=tutorialkit-starter build",
"docs": "pnpm run --filter=tutorialkit.dev dev",
"docs:build": "pnpm run --filter=tutorialkit.dev build",
"test": "pnpm run --filter=tutorialkit test"
"test": "pnpm run --filter=@tutorialkit/* --filter=tutorialkit test"
},
"license": "MIT",
"packageManager": "[email protected]",
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"@tutorialkit/runtime": "workspace:*",
"@tutorialkit/types": "workspace:*",
"@types/react": "^18.2.75",
"@webcontainer/api": "beta",
"@webcontainer/api": "1.2.0",
"astro-expressive-code": "^0.35.3",
"chokidar": "3.6.0",
"fast-glob": "^3.3.2",
Expand Down
2 changes: 1 addition & 1 deletion packages/components/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"@nanostores/react": "0.7.2",
"@radix-ui/react-accordion": "^1.1.2",
"@tutorialkit/runtime": "workspace:*",
"@webcontainer/api": "beta",
"@webcontainer/api": "1.2.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
Expand Down
9 changes: 6 additions & 3 deletions packages/runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,18 @@
}
},
"scripts": {
"build": "tsc -b"
"build": "tsc -b tsconfig.build.json",
"test": "vitest"
},
"dependencies": {
"@webcontainer/api": "beta",
"@webcontainer/api": "1.2.0",
"nanostores": "^0.10.3",
"@tutorialkit/types": "workspace:*"
},
"devDependencies": {
"test-utils": "workspace:*",
"typescript": "^5.4.5",
"vite": "^5.2.11"
"vite": "^5.2.11",
"vitest": "^1.6.0"
}
}
87 changes: 87 additions & 0 deletions packages/runtime/src/lesson-files.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, test, expect, beforeAll, vi, afterAll, beforeEach } from 'vitest';
import { LessonFilesFetcher } from './lesson-files.js';

const originalFetch = global.fetch;

let fetchBody: any;

const fetchSpy = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(fetchBody),
}),
);

beforeAll(() => {
global.fetch = fetchSpy as any;
});

beforeEach(() => {
fetchSpy.mockClear();
});

afterAll(() => {
global.fetch = originalFetch;
});

describe('LessonFilesFetcher', () => {
test('getLessonTemplate should fetch template', async () => {
fetchBody = { 'a.txt': 'content' };

const fetcher = new LessonFilesFetcher();
const files = await fetcher.getLessonTemplate({ data: { template: 'default' } } as any);

expect(files).toEqual({ 'a.txt': 'content' });
expect(fetchSpy).toHaveBeenCalledWith('/template-default.json', expect.anything());
});

test('getLessonFiles should fetch files', async () => {
fetchBody = { 'a.txt': 'content' };

const fetcher = new LessonFilesFetcher();
const files = await fetcher.getLessonFiles({
files: ['1-welcome-1-intro-1-welcome-files.json', ['a.txt']],
} as any);

expect(files).toEqual({ 'a.txt': 'content' });
expect(fetchSpy).toHaveBeenCalledWith('/1-welcome-1-intro-1-welcome-files.json');
});

test('getLessonSolution should fetch solution', async () => {
fetchBody = { 'a.txt': 'content' };

const fetcher = new LessonFilesFetcher();
const files = await fetcher.getLessonSolution({
solution: ['1-welcome-1-intro-1-welcome-solution.json', ['a.txt']],
} as any);

expect(files).toEqual({ 'a.txt': 'content' });
expect(fetchSpy).toHaveBeenCalledWith('/1-welcome-1-intro-1-welcome-solution.json');
});

test('invalidate should return none if files are not in map', async () => {
const fetcher = new LessonFilesFetcher();
const result = await fetcher.invalidate('1-welcome-1-intro-1-welcome-files.json');

expect(result).toEqual({ type: 'none' });
});

test('invalidate should fetch files', async () => {
fetchBody = { 'a.txt': 'content' };

const fetcher = new LessonFilesFetcher();

const initialData = await fetcher.getLessonFiles({
files: ['1-welcome-1-intro-1-welcome-files.json', ['a.txt']],
} as any);

expect(initialData).toEqual({ 'a.txt': 'content' });

fetchBody = { 'a.txt': 'foobar' };

const result = await fetcher.invalidate('1-welcome-1-intro-1-welcome-files.json');

expect(result).toEqual({ type: 'files', files: { 'a.txt': 'foobar' } });
expect(fetchSpy).toHaveBeenCalledWith('/1-welcome-1-intro-1-welcome-files.json');
});
});
130 changes: 130 additions & 0 deletions packages/runtime/src/tutorial-runner.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { resetProcessFactory, setProcessFactory } from 'test-utils';
import { WebContainer } from '@webcontainer/api';
import { describe, test, expect, beforeEach, vi } from 'vitest';
import type { MockedWebContainer } from 'test-utils';
import { TutorialRunner } from './tutorial-runner.js';
import { StepsController } from './webcontainer/steps.js';
import { TerminalStore } from './store/terminal.js';
import { withResolvers } from './utils/promises.js';

beforeEach(() => {
resetProcessFactory();
});

describe('TutorialRunner', () => {
test('prepareFiles should mount files to WebContainer', async () => {
const webcontainer = WebContainer.boot();
const mock = (await webcontainer) as MockedWebContainer;
const runner = new TutorialRunner(webcontainer, new TerminalStore(webcontainer, false), new StepsController());

await runner.prepareFiles({
files: {
'/src/index.js': 'console.log("Hello, world!")',
'/src/index.html': '<h1>Hello, world!</h1>',
},
template: {
'/package.json': '{ "name": "my-project" }',
},
});

expect(mock._fakeFs).toMatchInlineSnapshot(`
{
"package.json": {
"file": {
"contents": "{ "name": "my-project" }",
},
},
"src": {
"directory": {
"index.html": {
"file": {
"contents": "<h1>Hello, world!</h1>",
},
},
"index.js": {
"file": {
"contents": "console.log("Hello, world!")",
},
},
},
},
}
`);
});

test('runCommands should execute commands only after load has completed', async () => {
const webcontainer = WebContainer.boot();
const mock = (await webcontainer) as MockedWebContainer;

const { promise: runCommandPromise, resolve } = withResolvers();

const processFactory = vi.fn(() => {
resolve(JSON.stringify(mock._fakeFs, undefined, 2));

return {
exit: new Promise<number>(() => {}),
};
});

setProcessFactory(processFactory);

const runner = new TutorialRunner(webcontainer, new TerminalStore(webcontainer, false), new StepsController());

runner.setCommands({
mainCommand: 'some command',
});

runner.prepareFiles({
files: {
'/src/index.js': 'console.log("Hello, world!")',
'/src/index.html': '<h1>Hello, world!</h1>',
},
template: {
'/package.json': '{ "name": "my-project" }',
},
});

runner.runCommands();

const fs = await runCommandPromise;

expect(processFactory).toHaveBeenCalledTimes(1);
expect(fs).toMatchInlineSnapshot(`
"{
"package.json": {
"file": {
"contents": "{ \\"name\\": \\"my-project\\" }"
}
},
"src": {
"directory": {
"index.js": {
"file": {
"contents": "console.log(\\"Hello, world!\\")"
}
},
"index.html": {
"file": {
"contents": "<h1>Hello, world!</h1>"
}
}
}
}
}"
`);

expect(mock._fakeProcesses).toMatchInlineSnapshot(`
[
{
"args": [
"command",
],
"command": "some",
"options": {
"terminal": undefined,
},
},
]
`);
});
});
82 changes: 82 additions & 0 deletions packages/runtime/src/webcontainer/command.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';
import { Commands, Command } from './command.js';

describe('Commands', () => {
it('should accept shell like commands', () => {
const commands = new Commands({
mainCommand: 'npm run start',
prepareCommands: ['npm install'],
});

expect(commands.mainCommand?.shellCommand).toBe('npm run start');
expect(commands.prepareCommands?.[0].shellCommand).toBe('npm install');
});

it('should accept a tuple of [shell, title]', () => {
const commands = new Commands({
mainCommand: ['npm run start', 'Start dev server'],
prepareCommands: [['npm install', 'Installing dependencies']],
});

expect(commands.mainCommand?.shellCommand).toBe('npm run start');
expect(commands.mainCommand?.title).toBe('Start dev server');
expect(commands.prepareCommands?.[0].shellCommand).toBe('npm install');
expect(commands.prepareCommands?.[0].title).toBe('Installing dependencies');
});

it('should accept objects with { title, command }', () => {
const commands = new Commands({
mainCommand: { command: 'npm run start', title: 'Start dev server' },
prepareCommands: [{ command: 'npm install', title: 'Installing dependencies' }],
});

expect(commands.mainCommand?.shellCommand).toBe('npm run start');
expect(commands.mainCommand?.title).toBe('Start dev server');
expect(commands.prepareCommands?.[0].shellCommand).toBe('npm install');
expect(commands.prepareCommands?.[0].title).toBe('Installing dependencies');
});

it('should be iterable', () => {
const commands = new Commands({
mainCommand: 'npm run start',
prepareCommands: ['npm install', 'npm run build'],
});

const iterator: Iterator<Command> = commands[Symbol.iterator]();
expect(iterator.next().value.shellCommand).toBe('npm install');
expect(iterator.next().value.shellCommand).toBe('npm run build');
expect(iterator.next().value.shellCommand).toBe('npm run start');
expect(iterator.next().done).toBe(true);
});
});

describe('Command', () => {
it('should be runnable if shell command is not empty', () => {
const command = new Command('npm install');
expect(command.isRunnable()).toBe(true);
});

it('should not be runnable if shell command is empty', () => {
const command = new Command('');
expect(command.isRunnable()).toBe(false);
});

it('should compare commands by shell command', () => {
const a = new Command('npm install');
const b = new Command('npm install');
const c = new Command('npm run start');

expect(Command.equals(a, b)).toBe(true);
expect(Command.equals(a, c)).toBe(false);
});

it('should extract title from command', () => {
const command = new Command({ command: 'npm install', title: 'Installing dependencies' });
expect(command.title).toBe('Installing dependencies');
});

it('should convert command to shell command', () => {
const command = new Command({ command: 'npm install', title: 'Installing dependencies' });
expect(command.shellCommand).toBe('npm install');
});
});
Loading