UNPKG

@webcontainer/test

Version:

Utilities for testing applications in WebContainers

335 lines (328 loc) 9.6 kB
// src/fixtures/index.ts import { test as base } from "vitest"; // src/fixtures/preview.ts import { page } from "@vitest/browser/context"; var TEST_ID = "webcontainers-iframe"; var Preview = class { /** @internal */ _preview; /** @internal */ _iframe; constructor() { this._preview = page.getByTestId(TEST_ID); } /** @internal */ async setup(url) { const iframe = document.createElement("iframe"); iframe.setAttribute("src", url); iframe.setAttribute("style", "height: 100vh; width: 100vw;"); iframe.setAttribute("data-testid", TEST_ID); document.body.appendChild(iframe); this._iframe = iframe; } /** @internal */ async teardown() { if (this._iframe) { document.body.removeChild(this._iframe); } } /** * Vitest's [`getByRole`](https://vitest.dev/guide/browser/locators.html#getbyrole) that's scoped to the preview window. */ async getByRole(...options) { return this._preview.getByRole(...options); } /** * Vitest's [`getByText`](https://vitest.dev/guide/browser/locators.html#getbytext) that's scoped to the preview window. */ async getByText(...options) { return this._preview.getByText(...options); } /** * Vitest's [`locator`](https://vitest.dev/guide/browser/locators.html) of the preview window. */ get locator() { return this._preview; } }; // src/fixtures/webcontainer.ts import { WebContainer as WebContainerApi } from "@webcontainer/api"; // src/fixtures/file-system.ts import { commands } from "@vitest/browser/context"; var FileSystem = class { /** @internal */ get _instance() { 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) { if (typeof filesOrPath === "string") { const tree = await commands.readDirectory(filesOrPath); const binary = Uint8Array.from(atob(tree), (c) => c.charCodeAt(0)); return await this._instance.mount(binary); } return await this._instance.mount(filesOrPath); } /** * WebContainer's [`readFile`](https://webcontainers.io/guides/working-with-the-file-system#readfile) method. */ async readFile(path, encoding = "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, data, 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, newPath) { return this._instance.fs.rename(oldPath, newPath); } /** * WebContainer's [`mkdir`](https://webcontainers.io/guides/working-with-the-file-system#mkdir) method. */ async mkdir(path) { return this._instance.fs.mkdir(path); } /** * WebContainer's [`readdir`](https://webcontainers.io/guides/working-with-the-file-system#readdir) method. */ async readdir(path) { return this._instance.fs.readdir(path); } /** * WebContainer's [`rm`](https://webcontainers.io/guides/working-with-the-file-system#rm) method. */ async rm(path) { return this._instance.fs.rm(path); } /** @internal */ async export() { return await this._instance.export("./", { format: "binary" }); } /** @internal */ async restore(snapshot) { return await this._instance.mount(new Uint8Array(snapshot)); } }; // src/fixtures/process.ts var ProcessWrap = class { /** @internal */ _webcontainerProcess; /** @internal */ _isReady; /** @internal */ _output = ""; /** @internal */ _listeners = []; /** @internal */ _writer; /** * Wait for process to exit. */ isDone; constructor(promise) { let isExitted = false; let setDone = () => void 0; this.isDone = new Promise((resolve) => { setDone = () => { resolve(); isExitted = true; }; }); this._isReady = promise.then((webcontainerProcess) => { this._webcontainerProcess = webcontainerProcess; this._writer = webcontainerProcess.input.getWriter(); webcontainerProcess.exit.then(setDone); const reader = this._webcontainerProcess.output.getReader(); const read = async () => { while (true) { const { done, value } = await reader.read(); if (isExitted && !done) { console.warn( `[webcontainer-test]: Closed process keeps writing to output. Closing reader forcefully. Received: "${value}".` ); await reader.cancel(); break; } if (done) { break; } this._output += value; this._listeners.forEach((fn) => fn(value)); } }; void read(); }); } then(onfulfilled, onrejected) { return this.isDone.then(() => this._output.trim()).then(onfulfilled, onrejected); } /** * Write command into the process. */ write = async (text) => { 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, timeoutMs = 1e4) => { 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}". Received: ${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); }); }; /** * Listen for data stream chunks. */ onData = (listener) => { this._listeners.push(listener); }; /** * Exit the process. */ exit = async () => { await this._isReady; this._webcontainerProcess.kill(); this._listeners.splice(0); return this.isDone; }; }; // src/fixtures/webcontainer.ts var WebContainer = class extends FileSystem { /** @internal */ _instancePromise; /** @internal */ _isReady; /** @internal */ _onExit = []; constructor() { super(); this._isReady = new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error("WebContainer boot timed out in 30s")); }, 3e4); WebContainerApi.boot({}).then((instance) => { clearTimeout(timeout); this._instancePromise = instance; resolve(); }); }); } /** @internal */ get _instance() { if (!this._instancePromise) { throw new Error( "Webcontainer is not yet ready, make sure to call wait() after creation" ); } return this._instancePromise; } /** @internal */ async wait() { await this._isReady; } /** @internal */ onServerReady(callback) { this._instance.on("server-ready", (port, url) => { callback({ port, url }); }); } /** @internal */ async teardown() { await Promise.all(this._onExit.map((fn) => fn())); await this._instance._instance.teardown(); this._instance.teardown(); this._instancePromise = void 0; } /** * Run command inside WebContainer. * See [`runCommand` documentation](https://github.com/stackblitz/webcontainer-test#runcommand) for usage examples. */ runCommand(command, args = []) { const proc = new ProcessWrap( this._instance.spawn(command, args, { output: true }) ); this._onExit.push(() => proc.exit()); return proc; } }; // src/fixtures/index.ts var test = base.extend({ // @ts-ignore -- intentionally untyped, excluded from public API _internalState: { current: void 0 }, preview: async ({ webcontainer }, use) => { await webcontainer.wait(); const preview = new Preview(); webcontainer.onServerReady((options) => preview.setup(options.url)); await use(preview); await preview.teardown(); }, webcontainer: async ({}, use) => { const webcontainer = new WebContainer(); await webcontainer.wait(); await use(webcontainer); addEventListener("unhandledrejection", (event) => { if (event.reason instanceof Error && event.reason.message === "Process aborted") { return event.preventDefault(); } return Promise.reject(event.reason); }); await webcontainer.teardown(); }, // @ts-ignore -- intentionally untyped, excluded from public API setup: async ({ webcontainer, _internalState }, use) => { const internalState = _internalState; await use(async (callback) => { if (internalState.current) { await webcontainer.restore(internalState.current); return; } await callback(); internalState.current = await webcontainer.export(); }); } }); export { test };