Skip to content

Commit 9245a09

Browse files
authored
Add browser integration tests using Playwright (#222)
Currently just tests the streaming API on Chromium but it caught a bug with `ReadableStream`. I know we don't officially support the browser environments yet, but I do think we should ensure that it works if possible. It's relatively simple to setup a CloudFlare worker, for example, that applies the API token + CORS headers to a browser request for example should someone want to do such a thing.
1 parent b06a5f8 commit 9245a09

File tree

11 files changed

+180
-10
lines changed

11 files changed

+180
-10
lines changed

.github/workflows/ci.yml

+30-3
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,6 @@ jobs:
6464
# See supported Node.js release schedule at https://nodejs.org/en/about/previous-releases
6565
node-version: [18.x, 20.x]
6666
suite: [commonjs, esm, typescript]
67-
exclude:
68-
- suite: cloudflare-worker
69-
node-version: 18.x # Only test Cloudflare suite with the latest Node version
7067
fail-fast: false
7168

7269
steps:
@@ -84,6 +81,36 @@ jobs:
8481
npm --prefix integration/${{ matrix.suite }} install "./${{ needs.build.outputs.tarball-name }}"
8582
npm --prefix integration/${{ matrix.suite }} test
8683
84+
integration-browser:
85+
needs: [test, build]
86+
runs-on: ubuntu-latest
87+
88+
env:
89+
REPLICATE_API_TOKEN: ${{ secrets.REPLICATE_API_TOKEN }}
90+
91+
strategy:
92+
matrix:
93+
browser: ["chromium", "firefox", "webkit"]
94+
suite: ["browser"]
95+
fail-fast: false
96+
97+
steps:
98+
- uses: actions/checkout@v3
99+
- uses: actions/download-artifact@v3
100+
with:
101+
name: package-tarball
102+
- name: Use Node.js ${{ matrix.node-version }}
103+
uses: actions/setup-node@v3
104+
with:
105+
node-version: ${{ matrix.node-version }}
106+
cache: "npm"
107+
- run: |
108+
cd integration/${{ matrix.suite }}
109+
npm install
110+
npm install "../../${{ needs.build.outputs.tarball-name }}"
111+
npm exec -- playwright install ${{ matrix.browser }}
112+
npm exec -- playwright install-deps ${{ matrix.browser }}
113+
npm exec -- playwright test --browser ${{ matrix.browser }}
87114
88115
integration-edge:
89116
needs: [test, build]

index.js

+3-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const {
55
withAutomaticRetries,
66
validateWebhook,
77
parseProgressFromLogs,
8+
streamAsyncIterator,
89
} = require("./lib/util");
910

1011
const accounts = require("./lib/accounts");
@@ -296,7 +297,8 @@ class Replicate {
296297
fetch: this.fetch,
297298
options: { signal },
298299
});
299-
yield* stream;
300+
301+
yield* streamAsyncIterator(stream);
300302
} else {
301303
throw new Error("Prediction does not support streaming");
302304
}

integration/browser/.npmrc

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package-lock=false
2+
audit=false
3+
fund=false

integration/browser/README.md

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Browser integration tests
2+
3+
Uses [`playwright`](https://playwright.dev/docs) to run a basic integration test against the three most common browser engines, Firefox, Chromium and WebKit.
4+
5+
It uses the `replicate/canary` model for the moment, which requires a Replicate API token available in the environment under `REPLICATE_API_TOKEN`.
6+
7+
The entire suite is a single `main()` function that calls a single model exercising the streaming API.
8+
9+
The test uses `esbuild` within the test generate a browser friendly version of the `index.js` file which is loaded into the given browser and calls the `main()` function asserting the response content.
10+
11+
## CORS
12+
13+
The Replicate API doesn't support Cross Origin Resource Sharing at this time. We work around this in Playwright by intercepting the request in a `page.route` handler. We don't modify the request/response, but this seems to work around the restriction.
14+
15+
## Setup
16+
17+
npm install
18+
19+
## Local
20+
21+
The following command will run the tests across all browsers.
22+
23+
npm test
24+
25+
To run against the default browser (chromium) run:
26+
27+
npm exec playwright test
28+
29+
Or, specify a browser with:
30+
31+
npm exec playwright test --browser firefox
32+
33+
## Debugging
34+
35+
Running `playwright test` with the `--debug` flag opens a browser window with a debugging interface, and a breakpoint set at the start of the test. It can also be connected directly to VSCode.
36+
37+
npm exec playwright test --debug
38+
39+
The browser.js file is injected into the page via a script tag, to be able to set breakpoints in this file you'll need to use a `debugger` statement and open the devtools in the spawned browser window before continuing the test suite.

integration/browser/index.js

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import Replicate from "replicate";
2+
3+
/**
4+
* @param {string} - token the REPLICATE_API_TOKEN
5+
*/
6+
window.main = async (token) => {
7+
const replicate = new Replicate({ auth: token });
8+
const stream = replicate.stream(
9+
"replicate/canary:30e22229542eb3f79d4f945dacb58d32001b02cc313ae6f54eef27904edf3272",
10+
{
11+
input: {
12+
text: "Betty Browser",
13+
},
14+
}
15+
);
16+
17+
const output = [];
18+
for await (const event of stream) {
19+
output.push(String(event));
20+
}
21+
return output.join("");
22+
};

integration/browser/index.test.js

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { test, expect } from "@playwright/test";
2+
import { build } from "esbuild";
3+
4+
// Convert the source file from commonjs to a browser script.
5+
const result = await build({
6+
entryPoints: ["index.js"],
7+
bundle: true,
8+
platform: "browser",
9+
external: ["node:crypto"],
10+
write: false,
11+
});
12+
const source = new TextDecoder().decode(result.outputFiles[0].contents);
13+
14+
// https://playwright.dev/docs/network#modify-requests
15+
16+
test("browser", async ({ page }) => {
17+
// Patch the API endpoint to work around CORS for now.
18+
await page.route(
19+
"https://api.replicate.com/v1/predictions",
20+
async (route) => {
21+
// Fetch original response.
22+
const response = await route.fetch();
23+
// Add a prefix to the title.
24+
return route.fulfill({ response });
25+
}
26+
);
27+
28+
await page.addScriptTag({ content: source });
29+
const result = await page.evaluate(
30+
(token) => window.main(token),
31+
[process.env.REPLICATE_API_TOKEN]
32+
);
33+
expect(result).toBe("hello there, Betty Browser");
34+
});

integration/browser/package.json

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"name": "replicate-app-browser",
3+
"private": true,
4+
"version": "0.0.0",
5+
"description": "",
6+
"main": "index.js",
7+
"type": "module",
8+
"scripts": {
9+
"test": "playwright test --browser all"
10+
},
11+
"license": "ISC",
12+
"dependencies": {
13+
"replicate": "../../"
14+
},
15+
"devDependencies": {
16+
"@playwright/test": "^1.42.1",
17+
"esbuild": "^0.20.1"
18+
}
19+
}
+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { defineConfig } from "@playwright/test";
2+
3+
export default defineConfig({});

lib/stream.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Attempt to use readable-stream if available, attempt to use the built-in stream module.
22

33
const ApiError = require("./error");
4+
const { streamAsyncIterator } = require("./util");
45
const {
56
EventSourceParserStream,
67
} = require("../vendor/eventsource-parser/stream");
@@ -73,7 +74,7 @@ function createReadableStream({ url, fetch, options = {} }) {
7374
.pipeThrough(new TextDecoderStream())
7475
.pipeThrough(new EventSourceParserStream());
7576

76-
for await (const event of stream) {
77+
for await (const event of streamAsyncIterator(stream)) {
7778
if (event.event === "error") {
7879
controller.error(new Error(event.data));
7980
break;

lib/util.js

+24
Original file line numberDiff line numberDiff line change
@@ -354,9 +354,33 @@ function parseProgressFromLogs(input) {
354354
return null;
355355
}
356356

357+
/**
358+
* Helper to make any `ReadableStream` iterable, this is supported
359+
* by most server runtimes but browsers still haven't implemented
360+
* it yet.
361+
* See: https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream#browser_compatibility
362+
*
363+
* @template T
364+
* @param {ReadableStream<T>} stream an instance of a `ReadableStream`
365+
* @yields {T} a chunk/event from the stream
366+
*/
367+
async function* streamAsyncIterator(stream) {
368+
const reader = stream.getReader();
369+
try {
370+
while (true) {
371+
const { done, value } = await reader.read();
372+
if (done) return;
373+
yield value;
374+
}
375+
} finally {
376+
reader.releaseLock();
377+
}
378+
}
379+
357380
module.exports = {
358381
transformFileInputs,
359382
validateWebhook,
360383
withAutomaticRetries,
361384
parseProgressFromLogs,
385+
streamAsyncIterator,
362386
};

tsconfig.json

+1-5
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,5 @@
55
"strict": true,
66
"allowJs": true
77
},
8-
"exclude": [
9-
"**/node_modules",
10-
"integration/**"
11-
]
8+
"exclude": ["integration/**", "**/node_modules"]
129
}
13-

0 commit comments

Comments
 (0)