Skip to content

Commit 9006766

Browse files
committed
feat: runCommand to support interactive write+read
1 parent 13ff6fc commit 9006766

File tree

4 files changed

+154
-27
lines changed

4 files changed

+154
-27
lines changed

README.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,14 +111,33 @@ await webcontainer.mount({
111111

112112
##### `runCommand`
113113

114-
Run command inside webcontainer. Returns command output.
114+
Run command inside webcontainer.
115115

116116
```ts
117117
await webcontainer.runCommand("npm", ["install"]);
118+
```
119+
120+
Calling `await` on the result resolves into the command output:
118121

122+
```ts
119123
const files = await webcontainer.runCommand("ls", ["-l"]);
120124
```
121125

126+
To write into the output stream, use `write` method of the non-awaited output.
127+
128+
To verify output of continuous stream, use `waitForText()`:
129+
130+
```ts
131+
const { write, waitForText, isDone } = webcontainer.runCommand("npm", [
132+
"create",
133+
"vite",
134+
]);
135+
136+
await waitForText("What would you like to call your project?");
137+
await write("Example Project");
138+
await isDone;
139+
```
140+
122141
##### `readFile`
123142

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

src/fixtures/process.ts

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

src/fixtures/webcontainer.ts

Lines changed: 9 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { WebContainer as WebContainerApi } from "@webcontainer/api";
2+
23
import { FileSystem } from "./file-system";
4+
import { ProcessWrap } from "./process";
35

46
export class WebContainer extends FileSystem {
57
/** @internal */
@@ -55,33 +57,14 @@ export class WebContainer extends FileSystem {
5557

5658
/**
5759
* Run command inside WebContainer.
58-
* Returns the output of the command.
60+
* See [`runCommand` documentation](https://github.com/stackblitz/webcontainer-test#runcommand) for usage examples.
5961
*/
60-
async runCommand(command: string, args: string[] = []) {
61-
let output = "";
62-
63-
const process = await this._instance.spawn(command, args, { output: true });
64-
65-
process.output.pipeTo(
66-
new WritableStream({
67-
write(data) {
68-
output += data;
69-
},
70-
}),
62+
runCommand(
63+
command: string,
64+
args: string[] = [],
65+
): PromiseLike<string> & ProcessWrap {
66+
return new ProcessWrap(
67+
this._instance.spawn(command, args, { output: true }),
7168
);
72-
73-
// make sure any long-living processes are terminated before teardown, e.g. "npm run dev" commands
74-
this._onExit.push(() => {
75-
// @ts-ignore -- internal
76-
if (process._process != null) {
77-
process.kill();
78-
}
79-
80-
return process.exit;
81-
});
82-
83-
await process.exit;
84-
85-
return output.trim();
8669
}
8770
}

test/run-command.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,18 @@ test("user can run commands inside webcontainer", async ({ webcontainer }) => {
77

88
expect(output).toMatchInlineSnapshot(`"v20.19.0"`);
99
});
10+
11+
test("user can run interactive commands inside webcontainer", async ({
12+
webcontainer,
13+
}) => {
14+
const { exit, waitForText, write } = webcontainer.runCommand("node");
15+
await waitForText("Welcome to Node.js v20.19.0");
16+
17+
await write("console.log(20 + 19)\n");
18+
await waitForText("39");
19+
20+
await write("console.log(os.platform(), os.arch())\n");
21+
await waitForText("linux x64");
22+
23+
await exit();
24+
});

0 commit comments

Comments
 (0)