@webcontainer/test
Version:
Utilities for testing applications in WebContainers
335 lines (328 loc) • 9.6 kB
JavaScript
// 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
};