diff --git a/packages/connect-playwright-example/src/App.tsx b/packages/connect-playwright-example/src/App.tsx index a64f702..66e7900 100644 --- a/packages/connect-playwright-example/src/App.tsx +++ b/packages/connect-playwright-example/src/App.tsx @@ -13,8 +13,15 @@ // limitations under the License. import { useCallback, useState, FormEvent, FC } from "react"; -import { ConnectError, createPromiseClient } from "@connectrpc/connect"; -import { createConnectTransport } from "@connectrpc/connect-web"; +import { + ConnectError, + createPromiseClient, + PromiseClient, +} from "@connectrpc/connect"; +import { + createGrpcWebTransport, + createConnectTransport, +} from "@connectrpc/connect-web"; import { ElizaService } from "./gen/connectrpc/eliza/v1/eliza_connect.js"; interface ChatMessage { @@ -22,12 +29,35 @@ interface ChatMessage { sender: "eliza" | "user"; } -const elizaClient = createPromiseClient( - ElizaService, - createConnectTransport({ - baseUrl: "https://demo.connectrpc.com", - }), -); +const baseUrl = "https://demo.connectrpc.com"; +let elizaClient: PromiseClient; + +// Read the transport and format parameters from the URL +// Note that users do not need to worry about this since this is just for +// testing purposes so that we can easily verify various transports and +// serialization formats. +const params = new URLSearchParams(window.location.search); +const transportParam = params.get("transport"); +const useBinaryFormat = params.get("format") === "binary"; + +if (transportParam === "grpcweb") { + elizaClient = createPromiseClient( + ElizaService, + createGrpcWebTransport({ + baseUrl, + useBinaryFormat, + }), + ); +} else { + elizaClient = createPromiseClient( + ElizaService, + createConnectTransport({ + baseUrl, + useBinaryFormat, + useHttpGet: params.get("useHttpGet") === "true", + }), + ); +} const UnaryExample: FC = () => { const [inputValue, setInputValue] = useState(""); diff --git a/packages/connect-playwright-example/tests/more.spec.ts b/packages/connect-playwright-example/tests/more.spec.ts index 1fe9ade..205523b 100644 --- a/packages/connect-playwright-example/tests/more.spec.ts +++ b/packages/connect-playwright-example/tests/more.spec.ts @@ -39,7 +39,7 @@ test.describe("more mocking", () => { await page.goto(project.use.baseURL ?? ""); // Type some text and send - await statementInput.type("Hello"); + await statementInput.fill("Hello"); await sendButton.click(); // This should NOT be the mocked response and instead should be passed through @@ -54,7 +54,7 @@ test.describe("more mocking", () => { await page.goto(project.use.baseURL ?? ""); // Type a name and send - await statementInput.type("Hello"); + await statementInput.fill("Hello"); await sendButton.click(); // This should be empty text, because we configured the service with "mock" above, @@ -84,7 +84,7 @@ test.describe("more mocking", () => { await page.goto(project.use.baseURL ?? ""); // Type a name and send - await statementInput.type("Hello"); + await statementInput.fill("Hello"); await sendButton.click(); // This should be the mocked response we set in our call to mock.rpc() @@ -108,7 +108,7 @@ test.describe("more mocking", () => { await page.goto(project.use.baseURL ?? ""); // Type a name and send - await statementInput.type("Hello"); + await statementInput.fill("Hello"); await sendButton.click(); // This should be empty text, because we configured the service with "mock" above, @@ -131,7 +131,7 @@ test.describe("more mocking", () => { await page.goto(project.use.baseURL ?? ""); // Type a name and send - await statementInput.type("Hello"); + await statementInput.fill("Hello"); await sendButton.click(); // This should be the mocked response we set in our call to mock.rpc() @@ -151,7 +151,7 @@ test.describe("more mocking", () => { await page.goto(project.use.baseURL ?? ""); // Type a name and send - await statementInput.type("Hello"); + await statementInput.fill("Hello"); await sendButton.click(); // This should be empty text, because we configured the RPC with "mock" above, diff --git a/packages/connect-playwright-example/tests/simple.spec.ts b/packages/connect-playwright-example/tests/simple.spec.ts index 4b7af78..0335a02 100644 --- a/packages/connect-playwright-example/tests/simple.spec.ts +++ b/packages/connect-playwright-example/tests/simple.spec.ts @@ -47,7 +47,7 @@ test.describe("mocking Eliza", () => { await page.goto(project.use.baseURL ?? ""); // Type a name and send - await statementInput.type("Hello"); + await statementInput.fill("Hello"); await sendButton.click(); // This should be the mocked response we return from say() above @@ -60,7 +60,7 @@ test.describe("mocking Eliza", () => { await page.goto(project.use.baseURL ?? ""); // Type a name and send - await statementInput.type("Hello"); + await statementInput.fill("Hello"); await sendButton.click(); // This should be empty text, because we configured the service with "mock" above, @@ -81,7 +81,7 @@ test.describe("mocking Eliza", () => { await page.goto(project.use.baseURL ?? ""); // Type a name and send - await statementInput.type("Hello"); + await statementInput.fill("Hello"); await sendButton.click(); // In the app, calling say() rejected with a ConnectError. @@ -100,7 +100,7 @@ test.describe("mocking Eliza", () => { await page.goto(project.use.baseURL ?? ""); // Type a name and send - await statementInput.type("Hello"); + await statementInput.fill("Hello"); await sendButton.click(); // This should NOT be the mocked response and instead should be passed through diff --git a/packages/connect-playwright-example/tests/transport.spec.ts b/packages/connect-playwright-example/tests/transport.spec.ts new file mode 100644 index 0000000..715b3cd --- /dev/null +++ b/packages/connect-playwright-example/tests/transport.spec.ts @@ -0,0 +1,67 @@ +// Copyright 2023-2024 The Connect Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { expect, Locator, test } from "@playwright/test"; + +import { ElizaService } from "../src/gen/connectrpc/eliza/v1/eliza_connect.js"; +import { createMockRouter, MockRouter } from "@connectrpc/connect-playwright"; + +test.describe("transports", () => { + let respText: Locator; + let statementInput: Locator; + let sendButton: Locator; + let mock: MockRouter; + let baseURL = ""; + + test.beforeEach(async ({ page, context }, { project }) => { + respText = page.locator(".eliza-resp-container p"); + statementInput = page.locator("#statement-input"); + sendButton = page.locator("#send"); + + baseURL = project.use.baseURL ?? ""; + + mock = createMockRouter(context, { + baseUrl: "https://demo.connectrpc.com", + }); + + await mock.service(ElizaService, { + say() { + return { + sentence: "Mock response", + }; + }, + }); + }); + + [ + baseURL, + baseURL + "?transport=connect", + baseURL + "?transport=connect&useHttpGet=true", + baseURL + "?transport=connect&format=binary", + baseURL + "?transport=connect&format=binary&useHttpGet=true", + baseURL + "?transport=grpcweb", + baseURL + "?transport=grpcweb&format=json", + ].forEach((url) => { + test(`correctly mocks with params ${url}`, async ({ page }) => { + await page.goto(url); + + // Type a name and send + await statementInput.fill("Hello"); + await sendButton.click(); + + // This should be the mocked response we return from say() above + await expect(respText).toHaveText("Mock response"); + }); + }); +}); diff --git a/packages/connect-playwright/src/create-mock-router.ts b/packages/connect-playwright/src/create-mock-router.ts index 59dd4cd..8da5b4d 100644 --- a/packages/connect-playwright/src/create-mock-router.ts +++ b/packages/connect-playwright/src/create-mock-router.ts @@ -21,11 +21,18 @@ import type { BinaryReadOptions, BinaryWriteOptions, JsonReadOptions, + JsonValue, JsonWriteOptions, } from "@bufbuild/protobuf"; import { MethodKind } from "@bufbuild/protobuf"; -import type { UniversalHandler } from "@connectrpc/connect/protocol"; -import { readAllBytes } from "@connectrpc/connect/protocol"; +import type { + UniversalHandler, + UniversalServerRequest, +} from "@connectrpc/connect/protocol"; +import { + readAllBytes, + createAsyncIterable, +} from "@connectrpc/connect/protocol"; export interface MockRouter { service: ( @@ -55,6 +62,17 @@ interface Options { binaryOptions?: Partial; } +// Builds a regular expression for matching paths by appending the suffix onto +// base and escaping forward slashes and periods +function buildPathRegex(base: string, suffix: string) { + const sanitized = base + .replace(/\/?$/, suffix) + .replace(/\./g, "\\.") + .replace(/\//g, "\\/"); + + return new RegExp(sanitized); +} + export function createMockRouter( context: BrowserContext, options: Options, @@ -74,11 +92,13 @@ export function createMockRouter( if (method.kind !== MethodKind.Unary) { throw new Error("Cannot add non-unary method."); } - const requestPath = baseUrl - .toString() - .replace(/\/?$/, `/${service.typeName}/${method.name}`); - return context.route(requestPath, async (route, request) => { + const pathRegex = buildPathRegex( + baseUrl, + `/${service.typeName}/${method.name}`, + ); + + return context.route(pathRegex, async (route, request) => { if (handler !== "mock") { const router = createConnectRouter(routerOptions).rpc( service, @@ -122,11 +142,9 @@ export function createMockRouter( }), ); } - const requestPath = baseUrl - .toString() - .replace(/\/?$/, `/${service.typeName}/**`); + const pathRegex = buildPathRegex(baseUrl, `/${service.typeName}/*`); - return context.route(requestPath, async (route, request) => { + return context.route(pathRegex, async (route, request) => { const remainingPath = new URL(request.url()).pathname.replace( new RegExp(`^/${service.typeName}/`), "", @@ -184,18 +202,26 @@ async function universalHandlerToRouteResponse({ }) { const headers = await request.allHeaders(); const abortSignal = new AbortController().signal; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- The Serializable type isn't exposed by Playwright - let body: any; + + // Default body to an empty byte stream + let body: UniversalServerRequest["body"]; + if (headers["content-type"] === "application/json") { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - body = request.postDataJSON(); + // If content type headers are present and set to JSON, this is a POST + // request with a JSON body + body = request.postDataJSON() as JsonValue; } else { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - body = request.postDataBuffer(); + const buffer = request.postDataBuffer(); + if (buffer !== null) { + // If postDataBuffer returns a non-null body, this is a POST + // request with a binary body + body = createAsyncIterable([buffer]); + } else { + body = createAsyncIterable([]); + } } const response = await routeHandler({ - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- No current way around this, we just have no idea what this returns body, url: request.url(), header: new Headers(headers),