Skip to content

Commit 9db6e5a

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

File tree

5 files changed

+166
-29
lines changed

5 files changed

+166
-29
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/process.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
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+
73+
if ("captureStackTrace" in Error) {
74+
Error.captureStackTrace(error, this.waitForText);
75+
}
76+
77+
await this._isReady;
78+
79+
return new Promise<void>((resolve, reject) => {
80+
if (this._output.includes(expected)) {
81+
resolve();
82+
return;
83+
}
84+
85+
const timeout = setTimeout(() => {
86+
error.message = `Timeout when waiting for error "${expected}".\nReceived:\n${this._output}`;
87+
reject(error);
88+
}, timeoutMs);
89+
90+
const listener = () => {
91+
if (this._output.includes(expected)) {
92+
clearTimeout(timeout);
93+
this._listeners.splice(this._listeners.indexOf(listener), 1);
94+
95+
resolve();
96+
}
97+
};
98+
99+
this._listeners.push(listener);
100+
});
101+
};
102+
103+
/**
104+
* Exit the process.
105+
*/
106+
exit = async () => {
107+
await this._isReady;
108+
109+
this._webcontainerProcess.kill();
110+
111+
return this.isDone;
112+
};
113+
}

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/preview.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ test("user can see server output in preview", async ({
77
await webcontainer.mount("test/fixtures/starter-vite");
88

99
await webcontainer.runCommand("npm", ["install"]);
10-
void webcontainer.runCommand("npm", ["run", "dev"]);
10+
11+
const { exit } = webcontainer.runCommand("npm", ["run", "dev"]);
1112

1213
await preview.getByRole("heading", { level: 1, name: "Hello Vite!" });
14+
await exit();
1315
});
1416

1517
test("user can see HMR changes in preview", async ({

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)