Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix mocking of gRPC-web and Connect GET requests #100

Merged
merged 10 commits into from
Jan 24, 2024
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 38 additions & 8 deletions packages/connect-playwright-example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,51 @@
// 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 {
text: string;
sender: "eliza" | "user";
}

const elizaClient = createPromiseClient(
ElizaService,
createConnectTransport({
baseUrl: "https://demo.connectrpc.com",
}),
);
const baseUrl = "https://demo.connectrpc.com";
let elizaClient: PromiseClient<typeof ElizaService>;

// Read the transport and format parameters from the URL
smaye81 marked this conversation as resolved.
Show resolved Hide resolved
// 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<string>("");
Expand Down
12 changes: 6 additions & 6 deletions packages/connect-playwright-example/tests/more.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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()
Expand All @@ -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,
Expand All @@ -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()
Expand All @@ -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,
Expand Down
8 changes: 4 additions & 4 deletions packages/connect-playwright-example/tests/simple.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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.
Expand All @@ -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
Expand Down
67 changes: 67 additions & 0 deletions packages/connect-playwright-example/tests/transport.spec.ts
Original file line number Diff line number Diff line change
@@ -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",
Comment on lines +48 to +54
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice to have coverage against regressions 🎉

].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");
});
});
});
60 changes: 43 additions & 17 deletions packages/connect-playwright/src/create-mock-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <S extends ServiceType>(
Expand Down Expand Up @@ -55,6 +62,17 @@ interface Options {
binaryOptions?: Partial<BinaryReadOptions & BinaryWriteOptions>;
}

// Builds a regular expression for matching paths by appending the suffix onto
// base and escaping forward slashes and periods
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

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,
Expand All @@ -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,
Expand Down Expand Up @@ -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}/`),
"",
Expand Down Expand Up @@ -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"] = createAsyncIterable<Uint8Array>(
[],
);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Not crucial here, but you can simply declare the variable without assigning anything, as long as all code paths assign before use.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, the only issue was there was a code path that didn't assign it (if buffer was null), but I fixed that so that I didn't initialize it. And now the AsyncIterable is only created just-in-time if needed.


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<Uint8Array>([buffer]);
}
}

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),
Expand Down