diff --git a/README.md b/README.md index 9a5706d..8bc26ae 100644 --- a/README.md +++ b/README.md @@ -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(); }); ``` @@ -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. diff --git a/src/fixtures/file-system.ts b/src/fixtures/file-system.ts new file mode 100644 index 0000000..fff58d8 --- /dev/null +++ b/src/fixtures/file-system.ts @@ -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); + } +} diff --git a/src/fixtures/index.ts b/src/fixtures/index.ts index e5751de..0538725 100644 --- a/src/fixtures/index.ts +++ b/src/fixtures/index.ts @@ -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(); * }); * ``` */ diff --git a/src/fixtures/process.ts b/src/fixtures/process.ts new file mode 100644 index 0000000..ede0e09 --- /dev/null +++ b/src/fixtures/process.ts @@ -0,0 +1,127 @@ +import { WebContainerProcess } from "@webcontainer/api"; + +export class ProcessWrap { + /** @internal */ + private _webcontainerProcess!: WebContainerProcess; + + /** @internal */ + private _isReady: Promise; + + /** @internal */ + private _output: string = ""; + + /** @internal */ + private _listeners: (() => void)[] = []; + + /** @internal */ + private _writer?: ReturnType; + + /** + * Wait for process to exit. + */ + isDone: Promise; + + constructor(promise: Promise) { + 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( + onfulfilled?: ((value: string) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, + ): Promise { + 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((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; + }; +} diff --git a/src/fixtures/webcontainer.ts b/src/fixtures/webcontainer.ts index 712913c..0eea9fe 100644 --- a/src/fixtures/webcontainer.ts +++ b/src/fixtures/webcontainer.ts @@ -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; @@ -16,12 +14,15 @@ export class WebContainer { private _onExit: (() => Promise)[] = []; 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", @@ -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())); @@ -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 & 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; } } diff --git a/test/preview.test.ts b/test/preview.test.ts index dbf0fe9..77611d8 100644 --- a/test/preview.test.ts +++ b/test/preview.test.ts @@ -5,11 +5,12 @@ test("user can see server output in preview", async ({ preview, }) => { await webcontainer.mount("test/fixtures/starter-vite"); - await webcontainer.runCommand("npm", ["install"]); - void webcontainer.runCommand("npm", ["run", "dev"]); + + const { exit } = webcontainer.runCommand("npm", ["run", "dev"]); await preview.getByRole("heading", { level: 1, name: "Hello Vite!" }); + await exit(); }); test("user can see HMR changes in preview", async ({ @@ -17,18 +18,17 @@ test("user can see HMR changes in preview", async ({ preview, }) => { await webcontainer.mount("test/fixtures/starter-vite"); - await webcontainer.runCommand("npm", ["install"]); - void webcontainer.runCommand("npm", ["run", "dev"]); + const { exit } = webcontainer.runCommand("npm", ["run", "dev"]); await preview.getByRole("heading", { level: 1, name: "Hello Vite!" }); const content = await webcontainer.readFile("/src/main.js"); - await webcontainer.writeFile( "/src/main.js", content.replace("Hello Vite!", "Modified title!"), ); await preview.getByRole("heading", { level: 1, name: "Modified title!" }); + await exit(); }); diff --git a/test/run-command.test.ts b/test/run-command.test.ts index 6b1ea56..958df9d 100644 --- a/test/run-command.test.ts +++ b/test/run-command.test.ts @@ -7,3 +7,35 @@ test("user can run commands inside webcontainer", async ({ webcontainer }) => { expect(output).toMatchInlineSnapshot(`"v20.19.0"`); }); + +test("user can run interactive commands inside webcontainer", async ({ + webcontainer, +}) => { + const { exit, waitForText, write } = webcontainer.runCommand("node"); + await waitForText("Welcome to Node.js v20.19.0"); + + await write("console.log(20 + 19)\n"); + await waitForText("39"); + + await write("console.log(os.platform(), os.arch())\n"); + await waitForText("linux x64"); + + await exit(); +}); + +test("user can see timeout errors with clear description", async ({ + webcontainer, +}) => { + const { exit, waitForText, isDone } = webcontainer.runCommand("ls", ["/"]); + + await isDone; + + await expect(waitForText("This won't match anything", 10)).rejects + .toThrowErrorMatchingInlineSnapshot(` + [Error: Timeout when waiting for text "This won't match anything". + Received: + bin dev etc home tmp usr] + `); + + await exit(); +});