Skip to content

Commit e4a7170

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

File tree

7 files changed

+187
-33
lines changed

7 files changed

+187
-33
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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type {
88
export class FileSystem {
99
/** @internal */
1010
protected get _instance(): WebContainer {
11-
throw new Error("_instance should be overwritte");
11+
throw new Error("_instance should be overwritten");
1212
}
1313

1414
/**

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 error "${expected}".\nReceived:\n${this._output}`;
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: 11 additions & 24 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,18 @@ 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+
const proc = new ProcessWrap(
67+
this._instance.spawn(command, args, { output: true }),
7168
);
7269

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;
70+
this._onExit.push(() => proc.exit());
8471

85-
return output.trim();
72+
return proc;
8673
}
8774
}

test/preview.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,30 +5,30 @@ test("user can see server output in preview", async ({
55
preview,
66
}) => {
77
await webcontainer.mount("test/fixtures/starter-vite");
8-
98
await webcontainer.runCommand("npm", ["install"]);
10-
void webcontainer.runCommand("npm", ["run", "dev"]);
9+
10+
const { exit } = webcontainer.runCommand("npm", ["run", "dev"]);
1111

1212
await preview.getByRole("heading", { level: 1, name: "Hello Vite!" });
13+
await exit();
1314
});
1415

1516
test("user can see HMR changes in preview", async ({
1617
webcontainer,
1718
preview,
1819
}) => {
1920
await webcontainer.mount("test/fixtures/starter-vite");
20-
2121
await webcontainer.runCommand("npm", ["install"]);
22-
void webcontainer.runCommand("npm", ["run", "dev"]);
2322

23+
const { exit } = webcontainer.runCommand("npm", ["run", "dev"]);
2424
await preview.getByRole("heading", { level: 1, name: "Hello Vite!" });
2525

2626
const content = await webcontainer.readFile("/src/main.js");
27-
2827
await webcontainer.writeFile(
2928
"/src/main.js",
3029
content.replace("Hello Vite!", "Modified title!"),
3130
);
3231

3332
await preview.getByRole("heading", { level: 1, name: "Modified title!" });
33+
await exit();
3434
});

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)