diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b3dbba4..5fe3c62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,9 +64,6 @@ jobs: # See supported Node.js release schedule at https://nodejs.org/en/about/previous-releases node-version: [18.x, 20.x] suite: [commonjs, esm, typescript] - exclude: - - suite: cloudflare-worker - node-version: 18.x # Only test Cloudflare suite with the latest Node version fail-fast: false steps: @@ -84,6 +81,36 @@ jobs: npm --prefix integration/${{ matrix.suite }} install "./${{ needs.build.outputs.tarball-name }}" npm --prefix integration/${{ matrix.suite }} test + integration-browser: + needs: [test, build] + runs-on: ubuntu-latest + + env: + REPLICATE_API_TOKEN: ${{ secrets.REPLICATE_API_TOKEN }} + + strategy: + matrix: + browser: ["chromium", "firefox", "webkit"] + suite: ["browser"] + fail-fast: false + + steps: + - uses: actions/checkout@v3 + - uses: actions/download-artifact@v3 + with: + name: package-tarball + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + cache: "npm" + - run: | + cd integration/${{ matrix.suite }} + npm install + npm install "../../${{ needs.build.outputs.tarball-name }}" + npm exec -- playwright install ${{ matrix.browser }} + npm exec -- playwright install-deps ${{ matrix.browser }} + npm exec -- playwright test --browser ${{ matrix.browser }} integration-edge: needs: [test, build] diff --git a/index.js b/index.js index cd299f4..6c6590b 100644 --- a/index.js +++ b/index.js @@ -5,6 +5,7 @@ const { withAutomaticRetries, validateWebhook, parseProgressFromLogs, + streamAsyncIterator, } = require("./lib/util"); const accounts = require("./lib/accounts"); @@ -296,7 +297,8 @@ class Replicate { fetch: this.fetch, options: { signal }, }); - yield* stream; + + yield* streamAsyncIterator(stream); } else { throw new Error("Prediction does not support streaming"); } diff --git a/integration/browser/.npmrc b/integration/browser/.npmrc new file mode 100644 index 0000000..7775040 --- /dev/null +++ b/integration/browser/.npmrc @@ -0,0 +1,3 @@ +package-lock=false +audit=false +fund=false diff --git a/integration/browser/README.md b/integration/browser/README.md new file mode 100644 index 0000000..575d779 --- /dev/null +++ b/integration/browser/README.md @@ -0,0 +1,39 @@ +# Browser integration tests + +Uses [`playwright`](https://playwright.dev/docs) to run a basic integration test against the three most common browser engines, Firefox, Chromium and WebKit. + +It uses the `replicate/canary` model for the moment, which requires a Replicate API token available in the environment under `REPLICATE_API_TOKEN`. + +The entire suite is a single `main()` function that calls a single model exercising the streaming API. + +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. + +## CORS + +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. + +## Setup + + npm install + +## Local + +The following command will run the tests across all browsers. + + npm test + +To run against the default browser (chromium) run: + + npm exec playwright test + +Or, specify a browser with: + + npm exec playwright test --browser firefox + +## Debugging + +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. + + npm exec playwright test --debug + +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. diff --git a/integration/browser/index.js b/integration/browser/index.js new file mode 100644 index 0000000..9c0ae65 --- /dev/null +++ b/integration/browser/index.js @@ -0,0 +1,22 @@ +import Replicate from "replicate"; + +/** + * @param {string} - token the REPLICATE_API_TOKEN + */ +window.main = async (token) => { + const replicate = new Replicate({ auth: token }); + const stream = replicate.stream( + "replicate/canary:30e22229542eb3f79d4f945dacb58d32001b02cc313ae6f54eef27904edf3272", + { + input: { + text: "Betty Browser", + }, + } + ); + + const output = []; + for await (const event of stream) { + output.push(String(event)); + } + return output.join(""); +}; diff --git a/integration/browser/index.test.js b/integration/browser/index.test.js new file mode 100644 index 0000000..380d813 --- /dev/null +++ b/integration/browser/index.test.js @@ -0,0 +1,34 @@ +import { test, expect } from "@playwright/test"; +import { build } from "esbuild"; + +// Convert the source file from commonjs to a browser script. +const result = await build({ + entryPoints: ["index.js"], + bundle: true, + platform: "browser", + external: ["node:crypto"], + write: false, +}); +const source = new TextDecoder().decode(result.outputFiles[0].contents); + +// https://playwright.dev/docs/network#modify-requests + +test("browser", async ({ page }) => { + // Patch the API endpoint to work around CORS for now. + await page.route( + "https://api.replicate.com/v1/predictions", + async (route) => { + // Fetch original response. + const response = await route.fetch(); + // Add a prefix to the title. + return route.fulfill({ response }); + } + ); + + await page.addScriptTag({ content: source }); + const result = await page.evaluate( + (token) => window.main(token), + [process.env.REPLICATE_API_TOKEN] + ); + expect(result).toBe("hello there, Betty Browser"); +}); diff --git a/integration/browser/package.json b/integration/browser/package.json new file mode 100644 index 0000000..91ba179 --- /dev/null +++ b/integration/browser/package.json @@ -0,0 +1,19 @@ +{ + "name": "replicate-app-browser", + "private": true, + "version": "0.0.0", + "description": "", + "main": "index.js", + "type": "module", + "scripts": { + "test": "playwright test --browser all" + }, + "license": "ISC", + "dependencies": { + "replicate": "../../" + }, + "devDependencies": { + "@playwright/test": "^1.42.1", + "esbuild": "^0.20.1" + } +} diff --git a/integration/browser/playwright.config.ts b/integration/browser/playwright.config.ts new file mode 100644 index 0000000..142a177 --- /dev/null +++ b/integration/browser/playwright.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({}); diff --git a/lib/stream.js b/lib/stream.js index cd9274c..a6e5f84 100644 --- a/lib/stream.js +++ b/lib/stream.js @@ -1,6 +1,7 @@ // Attempt to use readable-stream if available, attempt to use the built-in stream module. const ApiError = require("./error"); +const { streamAsyncIterator } = require("./util"); const { EventSourceParserStream, } = require("../vendor/eventsource-parser/stream"); @@ -73,7 +74,7 @@ function createReadableStream({ url, fetch, options = {} }) { .pipeThrough(new TextDecoderStream()) .pipeThrough(new EventSourceParserStream()); - for await (const event of stream) { + for await (const event of streamAsyncIterator(stream)) { if (event.event === "error") { controller.error(new Error(event.data)); break; diff --git a/lib/util.js b/lib/util.js index ff9dacc..22a14c8 100644 --- a/lib/util.js +++ b/lib/util.js @@ -354,9 +354,33 @@ function parseProgressFromLogs(input) { return null; } +/** + * Helper to make any `ReadableStream` iterable, this is supported + * by most server runtimes but browsers still haven't implemented + * it yet. + * See: https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream#browser_compatibility + * + * @template T + * @param {ReadableStream} stream an instance of a `ReadableStream` + * @yields {T} a chunk/event from the stream + */ +async function* streamAsyncIterator(stream) { + const reader = stream.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) return; + yield value; + } + } finally { + reader.releaseLock(); + } +} + module.exports = { transformFileInputs, validateWebhook, withAutomaticRetries, parseProgressFromLogs, + streamAsyncIterator, }; diff --git a/tsconfig.json b/tsconfig.json index e6b4ed6..8a2eb35 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,9 +5,5 @@ "strict": true, "allowJs": true }, - "exclude": [ - "**/node_modules", - "integration/**" - ] + "exclude": ["integration/**", "**/node_modules"] } -