Skip to content
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
28 changes: 26 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,10 @@ test("run development server inside webcontainer", async ({
await webcontainer.mount("path/to/project");

await webcontainer.runCommand("npm", ["install"]);
webcontainer.runCommand("npm", ["run", "dev"]);
const { exit } = webcontainer.runCommand("npm", ["run", "dev"]);

await preview.getByRole("heading", { level: 1, name: "Hello Vite!" });
await exit();
});
```

Expand Down Expand Up @@ -111,14 +112,37 @@ await webcontainer.mount({

##### `runCommand`

Run command inside webcontainer. Returns command output.
Run command inside webcontainer.

```ts
await webcontainer.runCommand("npm", ["install"]);
```

Calling `await` on the result resolves into the command output:

```ts
const files = await webcontainer.runCommand("ls", ["-l"]);
```

To write into the output stream, use `write` method of the non-awaited output.

To verify output of continuous stream, use `waitForText()`:

```ts
const { write, waitForText, exit } = webcontainer.runCommand("npm", [
"create",
"vite",
]);

await waitForText("What would you like to call your project?");
await write("Example Project\n");

await waitForText("Where should the project be created?");
await write("./example-project\n");

await exit();
```

##### `readFile`

WebContainer's [`readFile`](https://webcontainers.io/guides/working-with-the-file-system#readfile) method.
Expand Down
67 changes: 67 additions & 0 deletions src/fixtures/file-system.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { commands } from "@vitest/browser/context";
import type {
FileSystemTree,
BufferEncoding,
WebContainer,
} from "@webcontainer/api";

export class FileSystem {
/** @internal */
protected get _instance(): WebContainer {
throw new Error("_instance should be overwritten");
}

/**
* Mount file directory into WebContainer.
* `string` arguments are considered paths that are relative to [`root`](https://vitest.dev/config/#root)
*/
async mount(filesOrPath: string | FileSystemTree) {
if (typeof filesOrPath === "string") {
filesOrPath = await commands.readDirectory(filesOrPath);
}

return await this._instance.mount(filesOrPath as FileSystemTree);
}

/**
* WebContainer's [`readFile`](https://webcontainers.io/guides/working-with-the-file-system#readfile) method.
*/
async readFile(path: string, encoding: BufferEncoding = "utf8") {
return this._instance.fs.readFile(path, encoding);
}

/**
* WebContainer's [`writeFile`](https://webcontainers.io/guides/working-with-the-file-system#writefile) method.
*/
async writeFile(path: string, data: string, encoding = "utf8") {
return this._instance.fs.writeFile(path, data, { encoding });
}

/**
* WebContainer's [`rename`](https://webcontainers.io/guides/working-with-the-file-system#rename) method.
*/
async rename(oldPath: string, newPath: string) {
return this._instance.fs.rename(oldPath, newPath);
}

/**
* WebContainer's [`mkdir`](https://webcontainers.io/guides/working-with-the-file-system#mkdir) method.
*/
async mkdir(path: string) {
return this._instance.fs.mkdir(path);
}

/**
* WebContainer's [`readdir`](https://webcontainers.io/guides/working-with-the-file-system#readdir) method.
*/
async readdir(path: string) {
return this._instance.fs.readdir(path);
}

/**
* WebContainer's [`rm`](https://webcontainers.io/guides/working-with-the-file-system#rm) method.
*/
async rm(path: string) {
return this._instance.fs.rm(path);
}
}
3 changes: 2 additions & 1 deletion src/fixtures/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ import { WebContainer } from "./webcontainer";
* await webcontainer.mount("path/to/project");
*
* await webcontainer.runCommand("npm", ["install"]);
* webcontainer.runCommand("npm", ["run", "dev"]);
* const { exit } = webcontainer.runCommand("npm", ["run", "dev"]);
*
* await preview.getByRole("heading", { level: 1, name: "Hello Vite!" });
* await exit();
* });
* ```
*/
Expand Down
127 changes: 127 additions & 0 deletions src/fixtures/process.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { WebContainerProcess } from "@webcontainer/api";

export class ProcessWrap {
/** @internal */
private _webcontainerProcess!: WebContainerProcess;

/** @internal */
private _isReady: Promise<void>;

/** @internal */
private _output: string = "";

/** @internal */
private _listeners: (() => void)[] = [];

/** @internal */
private _writer?: ReturnType<WebContainerProcess["input"]["getWriter"]>;

/**
* Wait for process to exit.
*/
isDone: Promise<void>;

constructor(promise: Promise<WebContainerProcess>) {
let setDone: () => void = () => undefined;
this.isDone = new Promise((resolve) => (setDone = resolve));

this._isReady = promise.then((webcontainerProcess) => {
this._webcontainerProcess = webcontainerProcess;
this._writer = webcontainerProcess.input.getWriter();

webcontainerProcess.exit.then(() => setDone());

this._webcontainerProcess.output.pipeTo(
new WritableStream({
write: (data) => {
this._output += data;
this._listeners.forEach((fn) => fn());
},
}),
);
});
}

then<TResult1 = string, TResult2 = never>(
onfulfilled?: ((value: string) => TResult1 | PromiseLike<TResult1>) | null,
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
): Promise<TResult1 | TResult2> {
return this.isDone
.then(() => this._output.trim())
.then(onfulfilled, onrejected);
}

/**
* Write command into the process.
*/
write = async (text: string) => {
await this._isReady;

this.resetCapturedText();

if (!this._writer) {
throw new Error("Process setup failed, writer not initialized");
}

return this._writer.write(text);
};

/**
* Reset captured output, so that `waitForText` does not match previous captured outputs.
*/
resetCapturedText = () => {
this._output = "";
};

/**
* Wait for process to output expected text.
*/
waitForText = async (expected: string, timeoutMs = 10_000) => {
const error = new Error("Timeout");

if ("captureStackTrace" in Error) {
Error.captureStackTrace(error, this.waitForText);
}

await this._isReady;

return new Promise<void>((resolve, reject) => {
if (this._output.includes(expected)) {
resolve();
return;
}

const timeout = setTimeout(() => {
error.message = `Timeout when waiting for text "${expected}".\nReceived:\n${this._output.trim()}`;
reject(error);
}, timeoutMs);

const listener = () => {
if (this._output.includes(expected)) {
clearTimeout(timeout);
this._listeners.splice(this._listeners.indexOf(listener), 1);

resolve();
}
};

this._listeners.push(listener);
});
};

/**
* Exit the process.
*/
exit = async () => {
await this._isReady;

// @ts-ignore -- internal check
if (this._webcontainerProcess._process != null) {
this._webcontainerProcess.kill();
}

this._listeners.splice(0);

return this.isDone;
};
}
106 changes: 19 additions & 87 deletions src/fixtures/webcontainer.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import { commands } from "@vitest/browser/context";
import {
type BufferEncoding,
type FileSystemTree,
WebContainer as WebContainerApi,
} from "@webcontainer/api";

export class WebContainer {
import { WebContainer as WebContainerApi } from "@webcontainer/api";

import { FileSystem } from "./file-system";
import { ProcessWrap } from "./process";

export class WebContainer extends FileSystem {
/** @internal */
private _instancePromise?: WebContainerApi;

Expand All @@ -16,12 +14,15 @@ export class WebContainer {
private _onExit: (() => Promise<unknown>)[] = [];

constructor() {
super();

this._isReady = WebContainerApi.boot({}).then((instance) => {
this._instancePromise = instance;
});
}

private get _instance(): WebContainerApi {
/** @internal */
protected get _instance(): WebContainerApi {
if (!this._instancePromise) {
throw new Error(
"Webcontainer is not yet ready, make sure to call wait() after creation",
Expand All @@ -43,18 +44,6 @@ export class WebContainer {
});
}

/**
* Mount file directory into WebContainer.
* `string` arguments are considered paths that are relative to [`root`](https://vitest.dev/config/#root)
*/
async mount(filesOrPath: string | FileSystemTree) {
if (typeof filesOrPath === "string") {
filesOrPath = await commands.readDirectory(filesOrPath);
}

return await this._instance.mount(filesOrPath as FileSystemTree);
}

/** @internal */
async teardown() {
await Promise.all(this._onExit.map((fn) => fn()));
Expand All @@ -68,75 +57,18 @@ export class WebContainer {

/**
* Run command inside WebContainer.
* Returns the output of the command.
* See [`runCommand` documentation](https://github.com/stackblitz/webcontainer-test#runcommand) for usage examples.
*/
async runCommand(command: string, args: string[] = []) {
let output = "";

const process = await this._instance.spawn(command, args, { output: true });

process.output.pipeTo(
new WritableStream({
write(data) {
output += data;
},
}),
runCommand(
command: string,
args: string[] = [],
): PromiseLike<string> & ProcessWrap {
const proc = new ProcessWrap(
this._instance.spawn(command, args, { output: true }),
);

// make sure any long-living processes are terminated before teardown, e.g. "npm run dev" commands
this._onExit.push(() => {
// @ts-ignore -- internal
if (process._process != null) {
process.kill();
}

return process.exit;
});

await process.exit;
this._onExit.push(() => proc.exit());

return output.trim();
}

/**
* WebContainer's [`readFile`](https://webcontainers.io/guides/working-with-the-file-system#readfile) method.
*/
async readFile(path: string, encoding: BufferEncoding = "utf8") {
return this._instance.fs.readFile(path, encoding);
}

/**
* WebContainer's [`writeFile`](https://webcontainers.io/guides/working-with-the-file-system#writefile) method.
*/
async writeFile(path: string, data: string, encoding = "utf8") {
return this._instance.fs.writeFile(path, data, { encoding });
}

/**
* WebContainer's [`rename`](https://webcontainers.io/guides/working-with-the-file-system#rename) method.
*/
async rename(oldPath: string, newPath: string) {
return this._instance.fs.rename(oldPath, newPath);
}

/**
* WebContainer's [`mkdir`](https://webcontainers.io/guides/working-with-the-file-system#mkdir) method.
*/
async mkdir(path: string) {
return this._instance.fs.mkdir(path);
}

/**
* WebContainer's [`readdir`](https://webcontainers.io/guides/working-with-the-file-system#readdir) method.
*/
async readdir(path: string) {
return this._instance.fs.readdir(path);
}

/**
* WebContainer's [`rm`](https://webcontainers.io/guides/working-with-the-file-system#rm) method.
*/
async rm(path: string) {
return this._instance.fs.rm(path);
return proc;
}
}
Loading