diff --git a/README.md b/README.md index 8e1076e..c05cc40 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,11 @@ export default defineConfig({ ## API -Webcontainer utilities are exposed as [test fixtures](https://vitest.dev/guide/test-context.html#test-extend). +Webcontainer utilities are exposed as [test fixtures](https://vitest.dev/guide/test-context.html#test-extend): + +- [`preview`](#preview) +- [`webcontainer`](#webcontainer) +- [`setup`](#setup) ```ts import { test } from "@webcontainer/test"; @@ -70,7 +74,7 @@ import { test, type TestContext } from "@webcontainer/test"; import { beforeEach } from "vitest"; // Mount project before each test -beforeEach(({ webcontainer }) => { +beforeEach(({ webcontainer }) => { await webcontainer.mount("projects/example"); }); ``` @@ -214,5 +218,34 @@ WebContainer's [`rm`](https://webcontainers.io/guides/working-with-the-file-syst await webcontainer.rm("/node_modules"); ``` +### `setup` + +If you have repetitive steps that are needed by multiple test cases, you can improve test performance by using `setup`. + +It calls the given function once, saves WebContainer state in a snapshot, and restores that snapshot before each test. + +```ts +import { test, type TestContext } from "@webcontainer/test"; +import { beforeEach, expect, onTestFinished } from "vitest"; + +beforeEach(async ({ webcontainer, setup }) => { + // This is run once and cached for each next run + await setup(async () => { + await webcontainer.mount("./svelte-project"); + await webcontainer.runCommand("npm", ["install"]); + }); +}); + +// No need to re-mount file system or re-run install in test cases +test("user can build project", async ({ webcontainer }) => { + await webcontainer.runCommand("npm", ["run", "build"]); +}); + +test("user can start project", async ({ webcontainer, preview }) => { + void webcontainer.runCommand("npm", ["run", "dev"]); + await preview.getByRole("heading", { name: "Welcome to SvelteKit" }); +}); +``` + [version-badge]: https://img.shields.io/npm/v/@webcontainer/test [npm-url]: https://www.npmjs.com/package/@webcontainer/test diff --git a/src/fixtures/file-system.ts b/src/fixtures/file-system.ts index 9a0fd21..655a7d8 100644 --- a/src/fixtures/file-system.ts +++ b/src/fixtures/file-system.ts @@ -67,4 +67,14 @@ export class FileSystem { async rm(path: string) { return this._instance.fs.rm(path); } + + /** @internal */ + async export() { + return await this._instance.export("./", { format: "binary" }); + } + + /** @internal */ + async restore(snapshot: Uint8Array) { + return await this._instance.mount(new Uint8Array(snapshot)); + } } diff --git a/src/fixtures/index.ts b/src/fixtures/index.ts index 6ad221d..e046a45 100644 --- a/src/fixtures/index.ts +++ b/src/fixtures/index.ts @@ -6,6 +6,10 @@ import { WebContainer } from "./webcontainer"; export interface TestContext { preview: Preview; webcontainer: WebContainer; + setup: (callback: () => Promise) => Promise; + + /** @internal */ + _internalState: { current: Uint8Array | undefined }; } /** @@ -29,7 +33,10 @@ export interface TestContext { * }); * ``` */ -export const test = base.extend({ +export const test = base.extend>({ + // @ts-ignore -- intentionally untyped, excluded from public API + _internalState: { current: undefined }, + preview: async ({ webcontainer }, use) => { await webcontainer.wait(); @@ -60,4 +67,21 @@ export const test = base.extend({ await webcontainer.teardown(); }, + + // @ts-ignore -- intentionally untyped, excluded from public API + setup: async ({ webcontainer, _internalState }, use) => { + const internalState = _internalState as TestContext["_internalState"]; + + await use(async (callback) => { + if (internalState.current) { + await webcontainer.restore(internalState.current); + return; + } + + await callback(); + + // save current state in fixture + internalState.current = await webcontainer.export(); + }); + }, }); diff --git a/test/setup.test.ts b/test/setup.test.ts new file mode 100644 index 0000000..4f36d8c --- /dev/null +++ b/test/setup.test.ts @@ -0,0 +1,23 @@ +import { beforeEach, expect } from "vitest"; +import { test, type TestContext } from "../src"; + +const counts = { setup: 0, beforeEach: 0 }; + +beforeEach(async ({ setup, webcontainer }) => { + await setup(async () => { + await webcontainer.writeFile("./example", "Hello world"); + counts.setup++; + }); + + counts.beforeEach++; +}); + +test.for([1, 2, 3])("state is restored %d", async (count, { webcontainer }) => { + await expect(webcontainer.readFile("example")).resolves.toBe("Hello world"); + + // setup should always be called just once as it's cached + expect(counts.setup).toBe(1); + + // hook itself is called each time + expect(counts.beforeEach).toBe(count); +});