Skip to content

Commit 2f2389e

Browse files
authored
feat: runCommand to support interactive write and read (#7)
1 parent 97fb8de commit 2f2389e

File tree

7 files changed

+278
-95
lines changed

7 files changed

+278
-95
lines changed

README.md

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,10 @@ test("run development server inside webcontainer", async ({
5656
await webcontainer.mount("path/to/project");
5757

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

6161
await preview.getByRole("heading", { level: 1, name: "Hello Vite!" });
62+
await exit();
6263
});
6364
```
6465

@@ -111,14 +112,37 @@ await webcontainer.mount({
111112

112113
##### `runCommand`
113114

114-
Run command inside webcontainer. Returns command output.
115+
Run command inside webcontainer.
115116

116117
```ts
117118
await webcontainer.runCommand("npm", ["install"]);
119+
```
120+
121+
Calling `await` on the result resolves into the command output:
118122

123+
```ts
119124
const files = await webcontainer.runCommand("ls", ["-l"]);
120125
```
121126

127+
To write into the output stream, use `write` method of the non-awaited output.
128+
129+
To verify output of continuous stream, use `waitForText()`:
130+
131+
```ts
132+
const { write, waitForText, exit } = webcontainer.runCommand("npm", [
133+
"create",
134+
"vite",
135+
]);
136+
137+
await waitForText("What would you like to call your project?");
138+
await write("Example Project\n");
139+
140+
await waitForText("Where should the project be created?");
141+
await write("./example-project\n");
142+
143+
await exit();
144+
```
145+
122146
##### `readFile`
123147

124148
WebContainer's [`readFile`](https://webcontainers.io/guides/working-with-the-file-system#readfile) method.

src/fixtures/file-system.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { commands } from "@vitest/browser/context";
2+
import type {
3+
FileSystemTree,
4+
BufferEncoding,
5+
WebContainer,
6+
} from "@webcontainer/api";
7+
8+
export class FileSystem {
9+
/** @internal */
10+
protected get _instance(): WebContainer {
11+
throw new Error("_instance should be overwritten");
12+
}
13+
14+
/**
15+
* Mount file directory into WebContainer.
16+
* `string` arguments are considered paths that are relative to [`root`](https://vitest.dev/config/#root)
17+
*/
18+
async mount(filesOrPath: string | FileSystemTree) {
19+
if (typeof filesOrPath === "string") {
20+
filesOrPath = await commands.readDirectory(filesOrPath);
21+
}
22+
23+
return await this._instance.mount(filesOrPath as FileSystemTree);
24+
}
25+
26+
/**
27+
* WebContainer's [`readFile`](https://webcontainers.io/guides/working-with-the-file-system#readfile) method.
28+
*/
29+
async readFile(path: string, encoding: BufferEncoding = "utf8") {
30+
return this._instance.fs.readFile(path, encoding);
31+
}
32+
33+
/**
34+
* WebContainer's [`writeFile`](https://webcontainers.io/guides/working-with-the-file-system#writefile) method.
35+
*/
36+
async writeFile(path: string, data: string, encoding = "utf8") {
37+
return this._instance.fs.writeFile(path, data, { encoding });
38+
}
39+
40+
/**
41+
* WebContainer's [`rename`](https://webcontainers.io/guides/working-with-the-file-system#rename) method.
42+
*/
43+
async rename(oldPath: string, newPath: string) {
44+
return this._instance.fs.rename(oldPath, newPath);
45+
}
46+
47+
/**
48+
* WebContainer's [`mkdir`](https://webcontainers.io/guides/working-with-the-file-system#mkdir) method.
49+
*/
50+
async mkdir(path: string) {
51+
return this._instance.fs.mkdir(path);
52+
}
53+
54+
/**
55+
* WebContainer's [`readdir`](https://webcontainers.io/guides/working-with-the-file-system#readdir) method.
56+
*/
57+
async readdir(path: string) {
58+
return this._instance.fs.readdir(path);
59+
}
60+
61+
/**
62+
* WebContainer's [`rm`](https://webcontainers.io/guides/working-with-the-file-system#rm) method.
63+
*/
64+
async rm(path: string) {
65+
return this._instance.fs.rm(path);
66+
}
67+
}

src/fixtures/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@ import { WebContainer } from "./webcontainer";
1717
* await webcontainer.mount("path/to/project");
1818
*
1919
* await webcontainer.runCommand("npm", ["install"]);
20-
* webcontainer.runCommand("npm", ["run", "dev"]);
20+
* const { exit } = webcontainer.runCommand("npm", ["run", "dev"]);
2121
*
2222
* await preview.getByRole("heading", { level: 1, name: "Hello Vite!" });
23+
* await exit();
2324
* });
2425
* ```
2526
*/

src/fixtures/process.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { WebContainerProcess } from "@webcontainer/api";
2+
3+
export class ProcessWrap {
4+
/** @internal */
5+
private _webcontainerProcess!: WebContainerProcess;
6+
7+
/** @internal */
8+
private _isReady: Promise<void>;
9+
10+
/** @internal */
11+
private _output: string = "";
12+
13+
/** @internal */
14+
private _listeners: (() => void)[] = [];
15+
16+
/** @internal */
17+
private _writer?: ReturnType<WebContainerProcess["input"]["getWriter"]>;
18+
19+
/**
20+
* Wait for process to exit.
21+
*/
22+
isDone: Promise<void>;
23+
24+
constructor(promise: Promise<WebContainerProcess>) {
25+
let setDone: () => void = () => undefined;
26+
this.isDone = new Promise((resolve) => (setDone = resolve));
27+
28+
this._isReady = promise.then((webcontainerProcess) => {
29+
this._webcontainerProcess = webcontainerProcess;
30+
this._writer = webcontainerProcess.input.getWriter();
31+
32+
webcontainerProcess.exit.then(() => setDone());
33+
34+
this._webcontainerProcess.output.pipeTo(
35+
new WritableStream({
36+
write: (data) => {
37+
this._output += data;
38+
this._listeners.forEach((fn) => fn());
39+
},
40+
}),
41+
);
42+
});
43+
}
44+
45+
then<TResult1 = string, TResult2 = never>(
46+
onfulfilled?: ((value: string) => TResult1 | PromiseLike<TResult1>) | null,
47+
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
48+
): Promise<TResult1 | TResult2> {
49+
return this.isDone
50+
.then(() => this._output.trim())
51+
.then(onfulfilled, onrejected);
52+
}
53+
54+
/**
55+
* Write command into the process.
56+
*/
57+
write = async (text: string) => {
58+
await this._isReady;
59+
60+
this.resetCapturedText();
61+
62+
if (!this._writer) {
63+
throw new Error("Process setup failed, writer not initialized");
64+
}
65+
66+
return this._writer.write(text);
67+
};
68+
69+
/**
70+
* Reset captured output, so that `waitForText` does not match previous captured outputs.
71+
*/
72+
resetCapturedText = () => {
73+
this._output = "";
74+
};
75+
76+
/**
77+
* Wait for process to output expected text.
78+
*/
79+
waitForText = async (expected: string, timeoutMs = 10_000) => {
80+
const error = new Error("Timeout");
81+
82+
if ("captureStackTrace" in Error) {
83+
Error.captureStackTrace(error, this.waitForText);
84+
}
85+
86+
await this._isReady;
87+
88+
return new Promise<void>((resolve, reject) => {
89+
if (this._output.includes(expected)) {
90+
resolve();
91+
return;
92+
}
93+
94+
const timeout = setTimeout(() => {
95+
error.message = `Timeout when waiting for text "${expected}".\nReceived:\n${this._output.trim()}`;
96+
reject(error);
97+
}, timeoutMs);
98+
99+
const listener = () => {
100+
if (this._output.includes(expected)) {
101+
clearTimeout(timeout);
102+
this._listeners.splice(this._listeners.indexOf(listener), 1);
103+
104+
resolve();
105+
}
106+
};
107+
108+
this._listeners.push(listener);
109+
});
110+
};
111+
112+
/**
113+
* Exit the process.
114+
*/
115+
exit = async () => {
116+
await this._isReady;
117+
118+
// @ts-ignore -- internal check
119+
if (this._webcontainerProcess._process != null) {
120+
this._webcontainerProcess.kill();
121+
}
122+
123+
this._listeners.splice(0);
124+
125+
return this.isDone;
126+
};
127+
}

src/fixtures/webcontainer.ts

Lines changed: 19 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
1-
import { commands } from "@vitest/browser/context";
2-
import {
3-
type BufferEncoding,
4-
type FileSystemTree,
5-
WebContainer as WebContainerApi,
6-
} from "@webcontainer/api";
7-
8-
export class WebContainer {
1+
import { WebContainer as WebContainerApi } from "@webcontainer/api";
2+
3+
import { FileSystem } from "./file-system";
4+
import { ProcessWrap } from "./process";
5+
6+
export class WebContainer extends FileSystem {
97
/** @internal */
108
private _instancePromise?: WebContainerApi;
119

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

1816
constructor() {
17+
super();
18+
1919
this._isReady = WebContainerApi.boot({}).then((instance) => {
2020
this._instancePromise = instance;
2121
});
2222
}
2323

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

46-
/**
47-
* Mount file directory into WebContainer.
48-
* `string` arguments are considered paths that are relative to [`root`](https://vitest.dev/config/#root)
49-
*/
50-
async mount(filesOrPath: string | FileSystemTree) {
51-
if (typeof filesOrPath === "string") {
52-
filesOrPath = await commands.readDirectory(filesOrPath);
53-
}
54-
55-
return await this._instance.mount(filesOrPath as FileSystemTree);
56-
}
57-
5847
/** @internal */
5948
async teardown() {
6049
await Promise.all(this._onExit.map((fn) => fn()));
@@ -68,75 +57,18 @@ export class WebContainer {
6857

6958
/**
7059
* Run command inside WebContainer.
71-
* Returns the output of the command.
60+
* See [`runCommand` documentation](https://github.com/stackblitz/webcontainer-test#runcommand) for usage examples.
7261
*/
73-
async runCommand(command: string, args: string[] = []) {
74-
let output = "";
75-
76-
const process = await this._instance.spawn(command, args, { output: true });
77-
78-
process.output.pipeTo(
79-
new WritableStream({
80-
write(data) {
81-
output += data;
82-
},
83-
}),
62+
runCommand(
63+
command: string,
64+
args: string[] = [],
65+
): PromiseLike<string> & ProcessWrap {
66+
const proc = new ProcessWrap(
67+
this._instance.spawn(command, args, { output: true }),
8468
);
8569

86-
// make sure any long-living processes are terminated before teardown, e.g. "npm run dev" commands
87-
this._onExit.push(() => {
88-
// @ts-ignore -- internal
89-
if (process._process != null) {
90-
process.kill();
91-
}
92-
93-
return process.exit;
94-
});
95-
96-
await process.exit;
70+
this._onExit.push(() => proc.exit());
9771

98-
return output.trim();
99-
}
100-
101-
/**
102-
* WebContainer's [`readFile`](https://webcontainers.io/guides/working-with-the-file-system#readfile) method.
103-
*/
104-
async readFile(path: string, encoding: BufferEncoding = "utf8") {
105-
return this._instance.fs.readFile(path, encoding);
106-
}
107-
108-
/**
109-
* WebContainer's [`writeFile`](https://webcontainers.io/guides/working-with-the-file-system#writefile) method.
110-
*/
111-
async writeFile(path: string, data: string, encoding = "utf8") {
112-
return this._instance.fs.writeFile(path, data, { encoding });
113-
}
114-
115-
/**
116-
* WebContainer's [`rename`](https://webcontainers.io/guides/working-with-the-file-system#rename) method.
117-
*/
118-
async rename(oldPath: string, newPath: string) {
119-
return this._instance.fs.rename(oldPath, newPath);
120-
}
121-
122-
/**
123-
* WebContainer's [`mkdir`](https://webcontainers.io/guides/working-with-the-file-system#mkdir) method.
124-
*/
125-
async mkdir(path: string) {
126-
return this._instance.fs.mkdir(path);
127-
}
128-
129-
/**
130-
* WebContainer's [`readdir`](https://webcontainers.io/guides/working-with-the-file-system#readdir) method.
131-
*/
132-
async readdir(path: string) {
133-
return this._instance.fs.readdir(path);
134-
}
135-
136-
/**
137-
* WebContainer's [`rm`](https://webcontainers.io/guides/working-with-the-file-system#rm) method.
138-
*/
139-
async rm(path: string) {
140-
return this._instance.fs.rm(path);
72+
return proc;
14173
}
14274
}

0 commit comments

Comments
 (0)