Generate Fern TypeSscript SDK (#3785)
This commit is contained in:
committed by
GitHub
parent
d55b9637c4
commit
2062adac66
13
skyvern-ts/client/tests/custom.test.ts
Normal file
13
skyvern-ts/client/tests/custom.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* This is a custom test file, if you wish to add more tests
|
||||
* to your SDK.
|
||||
* Be sure to mark this file in `.fernignore`.
|
||||
*
|
||||
* If you include example requests/responses in your fern definition,
|
||||
* you will have tests automatically generated for you.
|
||||
*/
|
||||
describe("test", () => {
|
||||
it("default", () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
29
skyvern-ts/client/tests/mock-server/MockServer.ts
Normal file
29
skyvern-ts/client/tests/mock-server/MockServer.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { RequestHandlerOptions } from "msw";
|
||||
import type { SetupServer } from "msw/node";
|
||||
|
||||
import { mockEndpointBuilder } from "./mockEndpointBuilder";
|
||||
|
||||
export interface MockServerOptions {
|
||||
baseUrl: string;
|
||||
server: SetupServer;
|
||||
}
|
||||
|
||||
export class MockServer {
|
||||
private readonly server: SetupServer;
|
||||
public readonly baseUrl: string;
|
||||
|
||||
constructor({ baseUrl, server }: MockServerOptions) {
|
||||
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
|
||||
this.server = server;
|
||||
}
|
||||
|
||||
public mockEndpoint(options?: RequestHandlerOptions): ReturnType<typeof mockEndpointBuilder> {
|
||||
const builder = mockEndpointBuilder({
|
||||
once: options?.once,
|
||||
onBuild: (handler) => {
|
||||
this.server.use(handler);
|
||||
},
|
||||
}).baseUrl(this.baseUrl);
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
106
skyvern-ts/client/tests/mock-server/MockServerPool.ts
Normal file
106
skyvern-ts/client/tests/mock-server/MockServerPool.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { setupServer } from "msw/node";
|
||||
|
||||
import { fromJson, toJson } from "../../src/core/json";
|
||||
import { MockServer } from "./MockServer";
|
||||
import { randomBaseUrl } from "./randomBaseUrl";
|
||||
|
||||
const mswServer = setupServer();
|
||||
interface MockServerOptions {
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
async function formatHttpRequest(request: Request, id?: string): Promise<string> {
|
||||
try {
|
||||
const clone = request.clone();
|
||||
const headers = [...clone.headers.entries()].map(([k, v]) => `${k}: ${v}`).join("\n");
|
||||
|
||||
let body = "";
|
||||
try {
|
||||
const contentType = clone.headers.get("content-type");
|
||||
if (contentType?.includes("application/json")) {
|
||||
body = toJson(fromJson(await clone.text()), undefined, 2);
|
||||
} else if (clone.body) {
|
||||
body = await clone.text();
|
||||
}
|
||||
} catch (_e) {
|
||||
body = "(unable to parse body)";
|
||||
}
|
||||
|
||||
const title = id ? `### Request ${id} ###\n` : "";
|
||||
const firstLine = `${title}${request.method} ${request.url.toString()} HTTP/1.1`;
|
||||
|
||||
return `\n${firstLine}\n${headers}\n\n${body || "(no body)"}\n`;
|
||||
} catch (e) {
|
||||
return `Error formatting request: ${e}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function formatHttpResponse(response: Response, id?: string): Promise<string> {
|
||||
try {
|
||||
const clone = response.clone();
|
||||
const headers = [...clone.headers.entries()].map(([k, v]) => `${k}: ${v}`).join("\n");
|
||||
|
||||
let body = "";
|
||||
try {
|
||||
const contentType = clone.headers.get("content-type");
|
||||
if (contentType?.includes("application/json")) {
|
||||
body = toJson(fromJson(await clone.text()), undefined, 2);
|
||||
} else if (clone.body) {
|
||||
body = await clone.text();
|
||||
}
|
||||
} catch (_e) {
|
||||
body = "(unable to parse body)";
|
||||
}
|
||||
|
||||
const title = id ? `### Response for ${id} ###\n` : "";
|
||||
const firstLine = `${title}HTTP/1.1 ${response.status} ${response.statusText}`;
|
||||
|
||||
return `\n${firstLine}\n${headers}\n\n${body || "(no body)"}\n`;
|
||||
} catch (e) {
|
||||
return `Error formatting response: ${e}`;
|
||||
}
|
||||
}
|
||||
|
||||
class MockServerPool {
|
||||
private servers: MockServer[] = [];
|
||||
|
||||
public createServer(options?: Partial<MockServerOptions>): MockServer {
|
||||
const baseUrl = options?.baseUrl || randomBaseUrl();
|
||||
const server = new MockServer({ baseUrl, server: mswServer });
|
||||
this.servers.push(server);
|
||||
return server;
|
||||
}
|
||||
|
||||
public getServers(): MockServer[] {
|
||||
return [...this.servers];
|
||||
}
|
||||
|
||||
public listen(): void {
|
||||
const onUnhandledRequest = process.env.LOG_LEVEL === "debug" ? "warn" : "bypass";
|
||||
mswServer.listen({ onUnhandledRequest });
|
||||
|
||||
if (process.env.LOG_LEVEL === "debug") {
|
||||
mswServer.events.on("request:start", async ({ request, requestId }) => {
|
||||
const formattedRequest = await formatHttpRequest(request, requestId);
|
||||
console.debug(`request:start\n${formattedRequest}`);
|
||||
});
|
||||
|
||||
mswServer.events.on("request:unhandled", async ({ request, requestId }) => {
|
||||
const formattedRequest = await formatHttpRequest(request, requestId);
|
||||
console.debug(`request:unhandled\n${formattedRequest}`);
|
||||
});
|
||||
|
||||
mswServer.events.on("response:mocked", async ({ request, response, requestId }) => {
|
||||
const formattedResponse = await formatHttpResponse(response, requestId);
|
||||
console.debug(`response:mocked\n${formattedResponse}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
this.servers = [];
|
||||
mswServer.close();
|
||||
}
|
||||
}
|
||||
|
||||
export const mockServerPool = new MockServerPool();
|
||||
215
skyvern-ts/client/tests/mock-server/mockEndpointBuilder.ts
Normal file
215
skyvern-ts/client/tests/mock-server/mockEndpointBuilder.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { type DefaultBodyType, type HttpHandler, HttpResponse, type HttpResponseResolver, http } from "msw";
|
||||
|
||||
import { url } from "../../src/core";
|
||||
import { toJson } from "../../src/core/json";
|
||||
import { withHeaders } from "./withHeaders";
|
||||
import { withJson } from "./withJson";
|
||||
|
||||
type HttpMethod = "all" | "get" | "post" | "put" | "delete" | "patch" | "options" | "head";
|
||||
|
||||
interface MethodStage {
|
||||
baseUrl(baseUrl: string): MethodStage;
|
||||
all(path: string): RequestHeadersStage;
|
||||
get(path: string): RequestHeadersStage;
|
||||
post(path: string): RequestHeadersStage;
|
||||
put(path: string): RequestHeadersStage;
|
||||
delete(path: string): RequestHeadersStage;
|
||||
patch(path: string): RequestHeadersStage;
|
||||
options(path: string): RequestHeadersStage;
|
||||
head(path: string): RequestHeadersStage;
|
||||
}
|
||||
|
||||
interface RequestHeadersStage extends RequestBodyStage, ResponseStage {
|
||||
header(name: string, value: string): RequestHeadersStage;
|
||||
headers(headers: Record<string, string>): RequestBodyStage;
|
||||
}
|
||||
|
||||
interface RequestBodyStage extends ResponseStage {
|
||||
jsonBody(body: unknown): ResponseStage;
|
||||
}
|
||||
|
||||
interface ResponseStage {
|
||||
respondWith(): ResponseStatusStage;
|
||||
}
|
||||
interface ResponseStatusStage {
|
||||
statusCode(statusCode: number): ResponseHeaderStage;
|
||||
}
|
||||
|
||||
interface ResponseHeaderStage extends ResponseBodyStage, BuildStage {
|
||||
header(name: string, value: string): ResponseHeaderStage;
|
||||
headers(headers: Record<string, string>): ResponseHeaderStage;
|
||||
}
|
||||
|
||||
interface ResponseBodyStage {
|
||||
jsonBody(body: unknown): BuildStage;
|
||||
}
|
||||
|
||||
interface BuildStage {
|
||||
build(): HttpHandler;
|
||||
}
|
||||
|
||||
export interface HttpHandlerBuilderOptions {
|
||||
onBuild?: (handler: HttpHandler) => void;
|
||||
once?: boolean;
|
||||
}
|
||||
|
||||
class RequestBuilder implements MethodStage, RequestHeadersStage, RequestBodyStage, ResponseStage {
|
||||
private method: HttpMethod = "get";
|
||||
private _baseUrl: string = "";
|
||||
private path: string = "/";
|
||||
private readonly predicates: ((resolver: HttpResponseResolver) => HttpResponseResolver)[] = [];
|
||||
private readonly handlerOptions?: HttpHandlerBuilderOptions;
|
||||
|
||||
constructor(options?: HttpHandlerBuilderOptions) {
|
||||
this.handlerOptions = options;
|
||||
}
|
||||
|
||||
baseUrl(baseUrl: string): MethodStage {
|
||||
this._baseUrl = baseUrl;
|
||||
return this;
|
||||
}
|
||||
|
||||
all(path: string): RequestHeadersStage {
|
||||
this.method = "all";
|
||||
this.path = path;
|
||||
return this;
|
||||
}
|
||||
|
||||
get(path: string): RequestHeadersStage {
|
||||
this.method = "get";
|
||||
this.path = path;
|
||||
return this;
|
||||
}
|
||||
|
||||
post(path: string): RequestHeadersStage {
|
||||
this.method = "post";
|
||||
this.path = path;
|
||||
return this;
|
||||
}
|
||||
|
||||
put(path: string): RequestHeadersStage {
|
||||
this.method = "put";
|
||||
this.path = path;
|
||||
return this;
|
||||
}
|
||||
|
||||
delete(path: string): RequestHeadersStage {
|
||||
this.method = "delete";
|
||||
this.path = path;
|
||||
return this;
|
||||
}
|
||||
|
||||
patch(path: string): RequestHeadersStage {
|
||||
this.method = "patch";
|
||||
this.path = path;
|
||||
return this;
|
||||
}
|
||||
|
||||
options(path: string): RequestHeadersStage {
|
||||
this.method = "options";
|
||||
this.path = path;
|
||||
return this;
|
||||
}
|
||||
|
||||
head(path: string): RequestHeadersStage {
|
||||
this.method = "head";
|
||||
this.path = path;
|
||||
return this;
|
||||
}
|
||||
|
||||
header(name: string, value: string): RequestHeadersStage {
|
||||
this.predicates.push((resolver) => withHeaders({ [name]: value }, resolver));
|
||||
return this;
|
||||
}
|
||||
|
||||
headers(headers: Record<string, string>): RequestBodyStage {
|
||||
this.predicates.push((resolver) => withHeaders(headers, resolver));
|
||||
return this;
|
||||
}
|
||||
|
||||
jsonBody(body: unknown): ResponseStage {
|
||||
if (body === undefined) {
|
||||
throw new Error("Undefined is not valid JSON. Do not call jsonBody if you want an empty body.");
|
||||
}
|
||||
this.predicates.push((resolver) => withJson(body, resolver));
|
||||
return this;
|
||||
}
|
||||
|
||||
respondWith(): ResponseStatusStage {
|
||||
return new ResponseBuilder(this.method, this.buildUrl(), this.predicates, this.handlerOptions);
|
||||
}
|
||||
|
||||
private buildUrl(): string {
|
||||
return url.join(this._baseUrl, this.path);
|
||||
}
|
||||
}
|
||||
|
||||
class ResponseBuilder implements ResponseStatusStage, ResponseHeaderStage, ResponseBodyStage, BuildStage {
|
||||
private readonly method: HttpMethod;
|
||||
private readonly url: string;
|
||||
private readonly requestPredicates: ((resolver: HttpResponseResolver) => HttpResponseResolver)[];
|
||||
private readonly handlerOptions?: HttpHandlerBuilderOptions;
|
||||
|
||||
private responseStatusCode: number = 200;
|
||||
private responseHeaders: Record<string, string> = {};
|
||||
private responseBody: DefaultBodyType = undefined;
|
||||
|
||||
constructor(
|
||||
method: HttpMethod,
|
||||
url: string,
|
||||
requestPredicates: ((resolver: HttpResponseResolver) => HttpResponseResolver)[],
|
||||
options?: HttpHandlerBuilderOptions,
|
||||
) {
|
||||
this.method = method;
|
||||
this.url = url;
|
||||
this.requestPredicates = requestPredicates;
|
||||
this.handlerOptions = options;
|
||||
}
|
||||
|
||||
public statusCode(code: number): ResponseHeaderStage {
|
||||
this.responseStatusCode = code;
|
||||
return this;
|
||||
}
|
||||
|
||||
public header(name: string, value: string): ResponseHeaderStage {
|
||||
this.responseHeaders[name] = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
public headers(headers: Record<string, string>): ResponseHeaderStage {
|
||||
this.responseHeaders = { ...this.responseHeaders, ...headers };
|
||||
return this;
|
||||
}
|
||||
|
||||
public jsonBody(body: unknown): BuildStage {
|
||||
if (body === undefined) {
|
||||
throw new Error("Undefined is not valid JSON. Do not call jsonBody if you expect an empty body.");
|
||||
}
|
||||
this.responseBody = toJson(body);
|
||||
return this;
|
||||
}
|
||||
|
||||
public build(): HttpHandler {
|
||||
const responseResolver: HttpResponseResolver = () => {
|
||||
const response = new HttpResponse(this.responseBody, {
|
||||
status: this.responseStatusCode,
|
||||
headers: this.responseHeaders,
|
||||
});
|
||||
// if no Content-Type header is set, delete the default text content type that is set
|
||||
if (Object.keys(this.responseHeaders).some((key) => key.toLowerCase() === "content-type") === false) {
|
||||
response.headers.delete("Content-Type");
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
const finalResolver = this.requestPredicates.reduceRight((acc, predicate) => predicate(acc), responseResolver);
|
||||
|
||||
const handler = http[this.method](this.url, finalResolver, this.handlerOptions);
|
||||
this.handlerOptions?.onBuild?.(handler);
|
||||
return handler;
|
||||
}
|
||||
}
|
||||
|
||||
export function mockEndpointBuilder(options?: HttpHandlerBuilderOptions): MethodStage {
|
||||
return new RequestBuilder(options);
|
||||
}
|
||||
4
skyvern-ts/client/tests/mock-server/randomBaseUrl.ts
Normal file
4
skyvern-ts/client/tests/mock-server/randomBaseUrl.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export function randomBaseUrl(): string {
|
||||
const randomString = Math.random().toString(36).substring(2, 15);
|
||||
return `http://${randomString}.localhost`;
|
||||
}
|
||||
10
skyvern-ts/client/tests/mock-server/setup.ts
Normal file
10
skyvern-ts/client/tests/mock-server/setup.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { afterAll, beforeAll } from "vitest";
|
||||
|
||||
import { mockServerPool } from "./MockServerPool";
|
||||
|
||||
beforeAll(() => {
|
||||
mockServerPool.listen();
|
||||
});
|
||||
afterAll(() => {
|
||||
mockServerPool.close();
|
||||
});
|
||||
70
skyvern-ts/client/tests/mock-server/withHeaders.ts
Normal file
70
skyvern-ts/client/tests/mock-server/withHeaders.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { type HttpResponseResolver, passthrough } from "msw";
|
||||
|
||||
/**
|
||||
* Creates a request matcher that validates if request headers match specified criteria
|
||||
* @param expectedHeaders - Headers to match against
|
||||
* @param resolver - Response resolver to execute if headers match
|
||||
*/
|
||||
export function withHeaders(
|
||||
expectedHeaders: Record<string, string | RegExp | ((value: string) => boolean)>,
|
||||
resolver: HttpResponseResolver,
|
||||
): HttpResponseResolver {
|
||||
return (args) => {
|
||||
const { request } = args;
|
||||
const { headers } = request;
|
||||
|
||||
const mismatches: Record<
|
||||
string,
|
||||
{ actual: string | null; expected: string | RegExp | ((value: string) => boolean) }
|
||||
> = {};
|
||||
|
||||
for (const [key, expectedValue] of Object.entries(expectedHeaders)) {
|
||||
const actualValue = headers.get(key);
|
||||
|
||||
if (actualValue === null) {
|
||||
mismatches[key] = { actual: null, expected: expectedValue };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof expectedValue === "function") {
|
||||
if (!expectedValue(actualValue)) {
|
||||
mismatches[key] = { actual: actualValue, expected: expectedValue };
|
||||
}
|
||||
} else if (expectedValue instanceof RegExp) {
|
||||
if (!expectedValue.test(actualValue)) {
|
||||
mismatches[key] = { actual: actualValue, expected: expectedValue };
|
||||
}
|
||||
} else if (expectedValue !== actualValue) {
|
||||
mismatches[key] = { actual: actualValue, expected: expectedValue };
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(mismatches).length > 0) {
|
||||
const formattedMismatches = formatHeaderMismatches(mismatches);
|
||||
console.error("Header mismatch:", formattedMismatches);
|
||||
return passthrough();
|
||||
}
|
||||
|
||||
return resolver(args);
|
||||
};
|
||||
}
|
||||
|
||||
function formatHeaderMismatches(
|
||||
mismatches: Record<string, { actual: string | null; expected: string | RegExp | ((value: string) => boolean) }>,
|
||||
): Record<string, { actual: string | null; expected: string }> {
|
||||
const formatted: Record<string, { actual: string | null; expected: string }> = {};
|
||||
|
||||
for (const [key, { actual, expected }] of Object.entries(mismatches)) {
|
||||
formatted[key] = {
|
||||
actual,
|
||||
expected:
|
||||
expected instanceof RegExp
|
||||
? expected.toString()
|
||||
: typeof expected === "function"
|
||||
? "[Function]"
|
||||
: expected,
|
||||
};
|
||||
}
|
||||
|
||||
return formatted;
|
||||
}
|
||||
158
skyvern-ts/client/tests/mock-server/withJson.ts
Normal file
158
skyvern-ts/client/tests/mock-server/withJson.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { type HttpResponseResolver, passthrough } from "msw";
|
||||
|
||||
import { fromJson, toJson } from "../../src/core/json";
|
||||
|
||||
/**
|
||||
* Creates a request matcher that validates if the request JSON body exactly matches the expected object
|
||||
* @param expectedBody - The exact body object to match against
|
||||
* @param resolver - Response resolver to execute if body matches
|
||||
*/
|
||||
export function withJson(expectedBody: unknown, resolver: HttpResponseResolver): HttpResponseResolver {
|
||||
return async (args) => {
|
||||
const { request } = args;
|
||||
|
||||
let clonedRequest: Request;
|
||||
let bodyText: string | undefined;
|
||||
let actualBody: unknown;
|
||||
try {
|
||||
clonedRequest = request.clone();
|
||||
bodyText = await clonedRequest.text();
|
||||
if (bodyText === "") {
|
||||
console.error("Request body is empty, expected a JSON object.");
|
||||
return passthrough();
|
||||
}
|
||||
actualBody = fromJson(bodyText);
|
||||
} catch (error) {
|
||||
console.error(`Error processing request body:\n\tError: ${error}\n\tBody: ${bodyText}`);
|
||||
return passthrough();
|
||||
}
|
||||
|
||||
const mismatches = findMismatches(actualBody, expectedBody);
|
||||
if (Object.keys(mismatches).filter((key) => !key.startsWith("pagination.")).length > 0) {
|
||||
console.error("JSON body mismatch:", toJson(mismatches, undefined, 2));
|
||||
return passthrough();
|
||||
}
|
||||
|
||||
return resolver(args);
|
||||
};
|
||||
}
|
||||
|
||||
function findMismatches(actual: any, expected: any): Record<string, { actual: any; expected: any }> {
|
||||
const mismatches: Record<string, { actual: any; expected: any }> = {};
|
||||
|
||||
if (typeof actual !== typeof expected) {
|
||||
if (areEquivalent(actual, expected)) {
|
||||
return {};
|
||||
}
|
||||
return { value: { actual, expected } };
|
||||
}
|
||||
|
||||
if (typeof actual !== "object" || actual === null || expected === null) {
|
||||
if (actual !== expected) {
|
||||
if (areEquivalent(actual, expected)) {
|
||||
return {};
|
||||
}
|
||||
return { value: { actual, expected } };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
if (Array.isArray(actual) && Array.isArray(expected)) {
|
||||
if (actual.length !== expected.length) {
|
||||
return { length: { actual: actual.length, expected: expected.length } };
|
||||
}
|
||||
|
||||
const arrayMismatches: Record<string, { actual: any; expected: any }> = {};
|
||||
for (let i = 0; i < actual.length; i++) {
|
||||
const itemMismatches = findMismatches(actual[i], expected[i]);
|
||||
if (Object.keys(itemMismatches).length > 0) {
|
||||
for (const [mismatchKey, mismatchValue] of Object.entries(itemMismatches)) {
|
||||
arrayMismatches[`[${i}]${mismatchKey === "value" ? "" : `.${mismatchKey}`}`] = mismatchValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return arrayMismatches;
|
||||
}
|
||||
|
||||
const actualKeys = Object.keys(actual);
|
||||
const expectedKeys = Object.keys(expected);
|
||||
|
||||
const allKeys = new Set([...actualKeys, ...expectedKeys]);
|
||||
|
||||
for (const key of allKeys) {
|
||||
if (!expectedKeys.includes(key)) {
|
||||
if (actual[key] === undefined) {
|
||||
continue; // Skip undefined values in actual
|
||||
}
|
||||
mismatches[key] = { actual: actual[key], expected: undefined };
|
||||
} else if (!actualKeys.includes(key)) {
|
||||
if (expected[key] === undefined) {
|
||||
continue; // Skip undefined values in expected
|
||||
}
|
||||
mismatches[key] = { actual: undefined, expected: expected[key] };
|
||||
} else if (
|
||||
typeof actual[key] === "object" &&
|
||||
actual[key] !== null &&
|
||||
typeof expected[key] === "object" &&
|
||||
expected[key] !== null
|
||||
) {
|
||||
const nestedMismatches = findMismatches(actual[key], expected[key]);
|
||||
if (Object.keys(nestedMismatches).length > 0) {
|
||||
for (const [nestedKey, nestedValue] of Object.entries(nestedMismatches)) {
|
||||
mismatches[`${key}${nestedKey === "value" ? "" : `.${nestedKey}`}`] = nestedValue;
|
||||
}
|
||||
}
|
||||
} else if (actual[key] !== expected[key]) {
|
||||
if (areEquivalent(actual[key], expected[key])) {
|
||||
continue;
|
||||
}
|
||||
mismatches[key] = { actual: actual[key], expected: expected[key] };
|
||||
}
|
||||
}
|
||||
|
||||
return mismatches;
|
||||
}
|
||||
|
||||
function areEquivalent(actual: unknown, expected: unknown): boolean {
|
||||
if (actual === expected) {
|
||||
return true;
|
||||
}
|
||||
if (isEquivalentBigInt(actual, expected)) {
|
||||
return true;
|
||||
}
|
||||
if (isEquivalentDatetime(actual, expected)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isEquivalentBigInt(actual: unknown, expected: unknown) {
|
||||
if (typeof actual === "number") {
|
||||
actual = BigInt(actual);
|
||||
}
|
||||
if (typeof expected === "number") {
|
||||
expected = BigInt(expected);
|
||||
}
|
||||
if (typeof actual === "bigint" && typeof expected === "bigint") {
|
||||
return actual === expected;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isEquivalentDatetime(str1: unknown, str2: unknown): boolean {
|
||||
if (typeof str1 !== "string" || typeof str2 !== "string") {
|
||||
return false;
|
||||
}
|
||||
const isoDatePattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/;
|
||||
if (!isoDatePattern.test(str1) || !isoDatePattern.test(str2)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const date1 = new Date(str1).getTime();
|
||||
const date2 = new Date(str2).getTime();
|
||||
return date1 === date2;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
11
skyvern-ts/client/tests/tsconfig.json
Normal file
11
skyvern-ts/client/tests/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": null,
|
||||
"rootDir": "..",
|
||||
"baseUrl": "..",
|
||||
"types": ["vitest/globals"]
|
||||
},
|
||||
"include": ["../src", "../tests"],
|
||||
"exclude": []
|
||||
}
|
||||
255
skyvern-ts/client/tests/unit/fetcher/Fetcher.test.ts
Normal file
255
skyvern-ts/client/tests/unit/fetcher/Fetcher.test.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import fs from "fs";
|
||||
import { join } from "path";
|
||||
import stream from "stream";
|
||||
import type { BinaryResponse } from "../../../src/core";
|
||||
import { type Fetcher, fetcherImpl } from "../../../src/core/fetcher/Fetcher";
|
||||
|
||||
describe("Test fetcherImpl", () => {
|
||||
it("should handle successful request", async () => {
|
||||
const mockArgs: Fetcher.Args = {
|
||||
url: "https://httpbin.org/post",
|
||||
method: "POST",
|
||||
headers: { "X-Test": "x-test-header" },
|
||||
body: { data: "test" },
|
||||
contentType: "application/json",
|
||||
requestType: "json",
|
||||
responseType: "json",
|
||||
};
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ data: "test" }), {
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await fetcherImpl(mockArgs);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.body).toEqual({ data: "test" });
|
||||
}
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://httpbin.org/post",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({ "X-Test": "x-test-header" }),
|
||||
body: JSON.stringify({ data: "test" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should send octet stream", async () => {
|
||||
const url = "https://httpbin.org/post/file";
|
||||
const mockArgs: Fetcher.Args = {
|
||||
url,
|
||||
method: "POST",
|
||||
headers: { "X-Test": "x-test-header" },
|
||||
contentType: "application/octet-stream",
|
||||
requestType: "bytes",
|
||||
responseType: "json",
|
||||
body: fs.createReadStream(join(__dirname, "test-file.txt")),
|
||||
};
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ data: "test" }), {
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await fetcherImpl(mockArgs);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
url,
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({ "X-Test": "x-test-header" }),
|
||||
body: expect.any(fs.ReadStream),
|
||||
}),
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.body).toEqual({ data: "test" });
|
||||
}
|
||||
});
|
||||
|
||||
it("should receive file as stream", async () => {
|
||||
const url = "https://httpbin.org/post/file";
|
||||
const mockArgs: Fetcher.Args = {
|
||||
url,
|
||||
method: "GET",
|
||||
headers: { "X-Test": "x-test-header" },
|
||||
responseType: "binary-response",
|
||||
};
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
stream.Readable.toWeb(fs.createReadStream(join(__dirname, "test-file.txt"))) as ReadableStream,
|
||||
{
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const result = await fetcherImpl(mockArgs);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
url,
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
headers: expect.objectContaining({ "X-Test": "x-test-header" }),
|
||||
}),
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
const body = result.body as BinaryResponse;
|
||||
expect(body).toBeDefined();
|
||||
expect(body.bodyUsed).toBe(false);
|
||||
expect(typeof body.stream).toBe("function");
|
||||
const stream = body.stream();
|
||||
expect(stream).toBeInstanceOf(ReadableStream);
|
||||
const reader = stream.getReader();
|
||||
const { value } = await reader.read();
|
||||
const decoder = new TextDecoder();
|
||||
const streamContent = decoder.decode(value);
|
||||
expect(streamContent).toBe("This is a test file!\n");
|
||||
expect(body.bodyUsed).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("should receive file as blob", async () => {
|
||||
const url = "https://httpbin.org/post/file";
|
||||
const mockArgs: Fetcher.Args = {
|
||||
url,
|
||||
method: "GET",
|
||||
headers: { "X-Test": "x-test-header" },
|
||||
responseType: "binary-response",
|
||||
};
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
stream.Readable.toWeb(fs.createReadStream(join(__dirname, "test-file.txt"))) as ReadableStream,
|
||||
{
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const result = await fetcherImpl(mockArgs);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
url,
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
headers: expect.objectContaining({ "X-Test": "x-test-header" }),
|
||||
}),
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
const body = result.body as BinaryResponse;
|
||||
expect(body).toBeDefined();
|
||||
expect(body.bodyUsed).toBe(false);
|
||||
expect(typeof body.blob).toBe("function");
|
||||
const blob = await body.blob();
|
||||
expect(blob).toBeInstanceOf(Blob);
|
||||
const reader = blob.stream().getReader();
|
||||
const { value } = await reader.read();
|
||||
const decoder = new TextDecoder();
|
||||
const streamContent = decoder.decode(value);
|
||||
expect(streamContent).toBe("This is a test file!\n");
|
||||
expect(body.bodyUsed).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("should receive file as arraybuffer", async () => {
|
||||
const url = "https://httpbin.org/post/file";
|
||||
const mockArgs: Fetcher.Args = {
|
||||
url,
|
||||
method: "GET",
|
||||
headers: { "X-Test": "x-test-header" },
|
||||
responseType: "binary-response",
|
||||
};
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
stream.Readable.toWeb(fs.createReadStream(join(__dirname, "test-file.txt"))) as ReadableStream,
|
||||
{
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const result = await fetcherImpl(mockArgs);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
url,
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
headers: expect.objectContaining({ "X-Test": "x-test-header" }),
|
||||
}),
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
const body = result.body as BinaryResponse;
|
||||
expect(body).toBeDefined();
|
||||
expect(body.bodyUsed).toBe(false);
|
||||
expect(typeof body.arrayBuffer).toBe("function");
|
||||
const arrayBuffer = await body.arrayBuffer();
|
||||
expect(arrayBuffer).toBeInstanceOf(ArrayBuffer);
|
||||
const decoder = new TextDecoder();
|
||||
const streamContent = decoder.decode(new Uint8Array(arrayBuffer));
|
||||
expect(streamContent).toBe("This is a test file!\n");
|
||||
expect(body.bodyUsed).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("should receive file as bytes", async () => {
|
||||
const url = "https://httpbin.org/post/file";
|
||||
const mockArgs: Fetcher.Args = {
|
||||
url,
|
||||
method: "GET",
|
||||
headers: { "X-Test": "x-test-header" },
|
||||
responseType: "binary-response",
|
||||
};
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
stream.Readable.toWeb(fs.createReadStream(join(__dirname, "test-file.txt"))) as ReadableStream,
|
||||
{
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const result = await fetcherImpl(mockArgs);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
url,
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
headers: expect.objectContaining({ "X-Test": "x-test-header" }),
|
||||
}),
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
const body = result.body as BinaryResponse;
|
||||
expect(body).toBeDefined();
|
||||
expect(body.bodyUsed).toBe(false);
|
||||
expect(typeof body.bytes).toBe("function");
|
||||
if (!body.bytes) {
|
||||
return;
|
||||
}
|
||||
const bytes = await body.bytes();
|
||||
expect(bytes).toBeInstanceOf(Uint8Array);
|
||||
const decoder = new TextDecoder();
|
||||
const streamContent = decoder.decode(bytes);
|
||||
expect(streamContent).toBe("This is a test file!\n");
|
||||
expect(body.bodyUsed).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
143
skyvern-ts/client/tests/unit/fetcher/HttpResponsePromise.test.ts
Normal file
143
skyvern-ts/client/tests/unit/fetcher/HttpResponsePromise.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { HttpResponsePromise } from "../../../src/core/fetcher/HttpResponsePromise";
|
||||
import type { RawResponse, WithRawResponse } from "../../../src/core/fetcher/RawResponse";
|
||||
|
||||
describe("HttpResponsePromise", () => {
|
||||
const mockRawResponse: RawResponse = {
|
||||
headers: new Headers(),
|
||||
redirected: false,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
type: "basic" as ResponseType,
|
||||
url: "https://example.com",
|
||||
};
|
||||
const mockData = { id: "123", name: "test" };
|
||||
const mockWithRawResponse: WithRawResponse<typeof mockData> = {
|
||||
data: mockData,
|
||||
rawResponse: mockRawResponse,
|
||||
};
|
||||
|
||||
describe("fromFunction", () => {
|
||||
it("should create an HttpResponsePromise from a function", async () => {
|
||||
const mockFn = vi
|
||||
.fn<(arg1: string, arg2: string) => Promise<WithRawResponse<typeof mockData>>>()
|
||||
.mockResolvedValue(mockWithRawResponse);
|
||||
|
||||
const responsePromise = HttpResponsePromise.fromFunction(mockFn, "arg1", "arg2");
|
||||
|
||||
const result = await responsePromise;
|
||||
expect(result).toEqual(mockData);
|
||||
expect(mockFn).toHaveBeenCalledWith("arg1", "arg2");
|
||||
|
||||
const resultWithRawResponse = await responsePromise.withRawResponse();
|
||||
expect(resultWithRawResponse).toEqual({
|
||||
data: mockData,
|
||||
rawResponse: mockRawResponse,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromPromise", () => {
|
||||
it("should create an HttpResponsePromise from a promise", async () => {
|
||||
const promise = Promise.resolve(mockWithRawResponse);
|
||||
|
||||
const responsePromise = HttpResponsePromise.fromPromise(promise);
|
||||
|
||||
const result = await responsePromise;
|
||||
expect(result).toEqual(mockData);
|
||||
|
||||
const resultWithRawResponse = await responsePromise.withRawResponse();
|
||||
expect(resultWithRawResponse).toEqual({
|
||||
data: mockData,
|
||||
rawResponse: mockRawResponse,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromExecutor", () => {
|
||||
it("should create an HttpResponsePromise from an executor function", async () => {
|
||||
const responsePromise = HttpResponsePromise.fromExecutor((resolve) => {
|
||||
resolve(mockWithRawResponse);
|
||||
});
|
||||
|
||||
const result = await responsePromise;
|
||||
expect(result).toEqual(mockData);
|
||||
|
||||
const resultWithRawResponse = await responsePromise.withRawResponse();
|
||||
expect(resultWithRawResponse).toEqual({
|
||||
data: mockData,
|
||||
rawResponse: mockRawResponse,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromResult", () => {
|
||||
it("should create an HttpResponsePromise from a result", async () => {
|
||||
const responsePromise = HttpResponsePromise.fromResult(mockWithRawResponse);
|
||||
|
||||
const result = await responsePromise;
|
||||
expect(result).toEqual(mockData);
|
||||
|
||||
const resultWithRawResponse = await responsePromise.withRawResponse();
|
||||
expect(resultWithRawResponse).toEqual({
|
||||
data: mockData,
|
||||
rawResponse: mockRawResponse,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Promise methods", () => {
|
||||
let responsePromise: HttpResponsePromise<typeof mockData>;
|
||||
|
||||
beforeEach(() => {
|
||||
responsePromise = HttpResponsePromise.fromResult(mockWithRawResponse);
|
||||
});
|
||||
|
||||
it("should support then() method", async () => {
|
||||
const result = await responsePromise.then((data) => ({
|
||||
...data,
|
||||
modified: true,
|
||||
}));
|
||||
|
||||
expect(result).toEqual({
|
||||
...mockData,
|
||||
modified: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should support catch() method", async () => {
|
||||
const errorResponsePromise = HttpResponsePromise.fromExecutor((_, reject) => {
|
||||
reject(new Error("Test error"));
|
||||
});
|
||||
|
||||
const catchSpy = vi.fn();
|
||||
await errorResponsePromise.catch(catchSpy);
|
||||
|
||||
expect(catchSpy).toHaveBeenCalled();
|
||||
const error = catchSpy.mock.calls[0]?.[0];
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toBe("Test error");
|
||||
});
|
||||
|
||||
it("should support finally() method", async () => {
|
||||
const finallySpy = vi.fn();
|
||||
await responsePromise.finally(finallySpy);
|
||||
|
||||
expect(finallySpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("withRawResponse", () => {
|
||||
it("should return both data and raw response", async () => {
|
||||
const responsePromise = HttpResponsePromise.fromResult(mockWithRawResponse);
|
||||
|
||||
const result = await responsePromise.withRawResponse();
|
||||
|
||||
expect(result).toEqual({
|
||||
data: mockData,
|
||||
rawResponse: mockRawResponse,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
34
skyvern-ts/client/tests/unit/fetcher/RawResponse.test.ts
Normal file
34
skyvern-ts/client/tests/unit/fetcher/RawResponse.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { toRawResponse } from "../../../src/core/fetcher/RawResponse";
|
||||
|
||||
describe("RawResponse", () => {
|
||||
describe("toRawResponse", () => {
|
||||
it("should convert Response to RawResponse by removing body, bodyUsed, and ok properties", () => {
|
||||
const mockHeaders = new Headers({ "content-type": "application/json" });
|
||||
const mockResponse = {
|
||||
body: "test body",
|
||||
bodyUsed: false,
|
||||
ok: true,
|
||||
headers: mockHeaders,
|
||||
redirected: false,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
type: "basic" as ResponseType,
|
||||
url: "https://example.com",
|
||||
};
|
||||
|
||||
const result = toRawResponse(mockResponse as unknown as Response);
|
||||
|
||||
expect("body" in result).toBe(false);
|
||||
expect("bodyUsed" in result).toBe(false);
|
||||
expect("ok" in result).toBe(false);
|
||||
expect(result.headers).toBe(mockHeaders);
|
||||
expect(result.redirected).toBe(false);
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.statusText).toBe("OK");
|
||||
expect(result.type).toBe("basic");
|
||||
expect(result.url).toBe("https://example.com");
|
||||
});
|
||||
});
|
||||
});
|
||||
160
skyvern-ts/client/tests/unit/fetcher/createRequestUrl.test.ts
Normal file
160
skyvern-ts/client/tests/unit/fetcher/createRequestUrl.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { createRequestUrl } from "../../../src/core/fetcher/createRequestUrl";
|
||||
|
||||
describe("Test createRequestUrl", () => {
|
||||
it("should return the base URL when no query parameters are provided", () => {
|
||||
const baseUrl = "https://api.example.com";
|
||||
expect(createRequestUrl(baseUrl)).toBe(baseUrl);
|
||||
});
|
||||
|
||||
it("should append simple query parameters", () => {
|
||||
const baseUrl = "https://api.example.com";
|
||||
const queryParams = { key: "value", another: "param" };
|
||||
expect(createRequestUrl(baseUrl, queryParams)).toBe("https://api.example.com?key=value&another=param");
|
||||
});
|
||||
|
||||
it("should handle array query parameters", () => {
|
||||
const baseUrl = "https://api.example.com";
|
||||
const queryParams = { items: ["a", "b", "c"] };
|
||||
expect(createRequestUrl(baseUrl, queryParams)).toBe("https://api.example.com?items=a&items=b&items=c");
|
||||
});
|
||||
|
||||
it("should handle object query parameters", () => {
|
||||
const baseUrl = "https://api.example.com";
|
||||
const queryParams = { filter: { name: "John", age: 30 } };
|
||||
expect(createRequestUrl(baseUrl, queryParams)).toBe(
|
||||
"https://api.example.com?filter%5Bname%5D=John&filter%5Bage%5D=30",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle mixed types of query parameters", () => {
|
||||
const baseUrl = "https://api.example.com";
|
||||
const queryParams = {
|
||||
simple: "value",
|
||||
array: ["x", "y"],
|
||||
object: { key: "value" },
|
||||
};
|
||||
expect(createRequestUrl(baseUrl, queryParams)).toBe(
|
||||
"https://api.example.com?simple=value&array=x&array=y&object%5Bkey%5D=value",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle empty query parameters object", () => {
|
||||
const baseUrl = "https://api.example.com";
|
||||
expect(createRequestUrl(baseUrl, {})).toBe(baseUrl);
|
||||
});
|
||||
|
||||
it("should encode special characters in query parameters", () => {
|
||||
const baseUrl = "https://api.example.com";
|
||||
const queryParams = { special: "a&b=c d" };
|
||||
expect(createRequestUrl(baseUrl, queryParams)).toBe("https://api.example.com?special=a%26b%3Dc%20d");
|
||||
});
|
||||
|
||||
// Additional tests for edge cases and different value types
|
||||
it("should handle numeric values", () => {
|
||||
const baseUrl = "https://api.example.com";
|
||||
const queryParams = { count: 42, price: 19.99, active: 1, inactive: 0 };
|
||||
expect(createRequestUrl(baseUrl, queryParams)).toBe(
|
||||
"https://api.example.com?count=42&price=19.99&active=1&inactive=0",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle boolean values", () => {
|
||||
const baseUrl = "https://api.example.com";
|
||||
const queryParams = { enabled: true, disabled: false };
|
||||
expect(createRequestUrl(baseUrl, queryParams)).toBe("https://api.example.com?enabled=true&disabled=false");
|
||||
});
|
||||
|
||||
it("should handle null and undefined values", () => {
|
||||
const baseUrl = "https://api.example.com";
|
||||
const queryParams = {
|
||||
valid: "value",
|
||||
nullValue: null,
|
||||
undefinedValue: undefined,
|
||||
emptyString: "",
|
||||
};
|
||||
expect(createRequestUrl(baseUrl, queryParams)).toBe(
|
||||
"https://api.example.com?valid=value&nullValue=&emptyString=",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle deeply nested objects", () => {
|
||||
const baseUrl = "https://api.example.com";
|
||||
const queryParams = {
|
||||
user: {
|
||||
profile: {
|
||||
name: "John",
|
||||
settings: { theme: "dark" },
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(createRequestUrl(baseUrl, queryParams)).toBe(
|
||||
"https://api.example.com?user%5Bprofile%5D%5Bname%5D=John&user%5Bprofile%5D%5Bsettings%5D%5Btheme%5D=dark",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle arrays of objects", () => {
|
||||
const baseUrl = "https://api.example.com";
|
||||
const queryParams = {
|
||||
users: [
|
||||
{ name: "John", age: 30 },
|
||||
{ name: "Jane", age: 25 },
|
||||
],
|
||||
};
|
||||
expect(createRequestUrl(baseUrl, queryParams)).toBe(
|
||||
"https://api.example.com?users%5Bname%5D=John&users%5Bage%5D=30&users%5Bname%5D=Jane&users%5Bage%5D=25",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle mixed arrays", () => {
|
||||
const baseUrl = "https://api.example.com";
|
||||
const queryParams = {
|
||||
mixed: ["string", 42, true, { key: "value" }],
|
||||
};
|
||||
expect(createRequestUrl(baseUrl, queryParams)).toBe(
|
||||
"https://api.example.com?mixed=string&mixed=42&mixed=true&mixed%5Bkey%5D=value",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle empty arrays", () => {
|
||||
const baseUrl = "https://api.example.com";
|
||||
const queryParams = { emptyArray: [] };
|
||||
expect(createRequestUrl(baseUrl, queryParams)).toBe(baseUrl);
|
||||
});
|
||||
|
||||
it("should handle empty objects", () => {
|
||||
const baseUrl = "https://api.example.com";
|
||||
const queryParams = { emptyObject: {} };
|
||||
expect(createRequestUrl(baseUrl, queryParams)).toBe(baseUrl);
|
||||
});
|
||||
|
||||
it("should handle special characters in keys", () => {
|
||||
const baseUrl = "https://api.example.com";
|
||||
const queryParams = { "key with spaces": "value", "key[with]brackets": "value" };
|
||||
expect(createRequestUrl(baseUrl, queryParams)).toBe(
|
||||
"https://api.example.com?key%20with%20spaces=value&key%5Bwith%5Dbrackets=value",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle URL with existing query parameters", () => {
|
||||
const baseUrl = "https://api.example.com?existing=param";
|
||||
const queryParams = { new: "value" };
|
||||
expect(createRequestUrl(baseUrl, queryParams)).toBe("https://api.example.com?existing=param?new=value");
|
||||
});
|
||||
|
||||
it("should handle complex nested structures", () => {
|
||||
const baseUrl = "https://api.example.com";
|
||||
const queryParams = {
|
||||
filters: {
|
||||
status: ["active", "pending"],
|
||||
category: {
|
||||
type: "electronics",
|
||||
subcategories: ["phones", "laptops"],
|
||||
},
|
||||
},
|
||||
sort: { field: "name", direction: "asc" },
|
||||
};
|
||||
expect(createRequestUrl(baseUrl, queryParams)).toBe(
|
||||
"https://api.example.com?filters%5Bstatus%5D=active&filters%5Bstatus%5D=pending&filters%5Bcategory%5D%5Btype%5D=electronics&filters%5Bcategory%5D%5Bsubcategories%5D=phones&filters%5Bcategory%5D%5Bsubcategories%5D=laptops&sort%5Bfield%5D=name&sort%5Bdirection%5D=asc",
|
||||
);
|
||||
});
|
||||
});
|
||||
65
skyvern-ts/client/tests/unit/fetcher/getRequestBody.test.ts
Normal file
65
skyvern-ts/client/tests/unit/fetcher/getRequestBody.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { getRequestBody } from "../../../src/core/fetcher/getRequestBody";
|
||||
import { RUNTIME } from "../../../src/core/runtime";
|
||||
|
||||
describe("Test getRequestBody", () => {
|
||||
it("should stringify body if not FormData in Node environment", async () => {
|
||||
if (RUNTIME.type === "node") {
|
||||
const body = { key: "value" };
|
||||
const result = await getRequestBody({
|
||||
body,
|
||||
type: "json",
|
||||
});
|
||||
expect(result).toBe('{"key":"value"}');
|
||||
}
|
||||
});
|
||||
|
||||
it("should return FormData in browser environment", async () => {
|
||||
if (RUNTIME.type === "browser") {
|
||||
const formData = new FormData();
|
||||
formData.append("key", "value");
|
||||
const result = await getRequestBody({
|
||||
body: formData,
|
||||
type: "file",
|
||||
});
|
||||
expect(result).toBe(formData);
|
||||
}
|
||||
});
|
||||
|
||||
it("should stringify body if not FormData in browser environment", async () => {
|
||||
if (RUNTIME.type === "browser") {
|
||||
const body = { key: "value" };
|
||||
const result = await getRequestBody({
|
||||
body,
|
||||
type: "json",
|
||||
});
|
||||
expect(result).toBe('{"key":"value"}');
|
||||
}
|
||||
});
|
||||
|
||||
it("should return the Uint8Array", async () => {
|
||||
const input = new Uint8Array([1, 2, 3]);
|
||||
const result = await getRequestBody({
|
||||
body: input,
|
||||
type: "bytes",
|
||||
});
|
||||
expect(result).toBe(input);
|
||||
});
|
||||
|
||||
it("should return the input for content-type 'application/x-www-form-urlencoded'", async () => {
|
||||
const input = "key=value&another=param";
|
||||
const result = await getRequestBody({
|
||||
body: input,
|
||||
type: "other",
|
||||
});
|
||||
expect(result).toBe(input);
|
||||
});
|
||||
|
||||
it("should JSON stringify objects", async () => {
|
||||
const input = { key: "value" };
|
||||
const result = await getRequestBody({
|
||||
body: input,
|
||||
type: "json",
|
||||
});
|
||||
expect(result).toBe('{"key":"value"}');
|
||||
});
|
||||
});
|
||||
77
skyvern-ts/client/tests/unit/fetcher/getResponseBody.test.ts
Normal file
77
skyvern-ts/client/tests/unit/fetcher/getResponseBody.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { getResponseBody } from "../../../src/core/fetcher/getResponseBody";
|
||||
import { RUNTIME } from "../../../src/core/runtime";
|
||||
|
||||
describe("Test getResponseBody", () => {
|
||||
it("should handle blob response type", async () => {
|
||||
const mockBlob = new Blob(["test"], { type: "text/plain" });
|
||||
const mockResponse = new Response(mockBlob);
|
||||
const result = await getResponseBody(mockResponse, "blob");
|
||||
// @ts-expect-error
|
||||
expect(result.constructor.name).toBe("Blob");
|
||||
});
|
||||
|
||||
it("should handle sse response type", async () => {
|
||||
if (RUNTIME.type === "node") {
|
||||
const mockStream = new ReadableStream();
|
||||
const mockResponse = new Response(mockStream);
|
||||
const result = await getResponseBody(mockResponse, "sse");
|
||||
expect(result).toBe(mockStream);
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle streaming response type", async () => {
|
||||
// Create a ReadableStream with some test data
|
||||
const encoder = new TextEncoder();
|
||||
const testData = "test stream data";
|
||||
const mockStream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(testData));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
const mockResponse = new Response(mockStream);
|
||||
const result = (await getResponseBody(mockResponse, "streaming")) as ReadableStream;
|
||||
|
||||
expect(result).toBeInstanceOf(ReadableStream);
|
||||
|
||||
// Read and verify the stream content
|
||||
const reader = result.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
const { value } = await reader.read();
|
||||
const streamContent = decoder.decode(value);
|
||||
expect(streamContent).toBe(testData);
|
||||
});
|
||||
|
||||
it("should handle text response type", async () => {
|
||||
const mockResponse = new Response("test text");
|
||||
const result = await getResponseBody(mockResponse, "text");
|
||||
expect(result).toBe("test text");
|
||||
});
|
||||
|
||||
it("should handle JSON response", async () => {
|
||||
const mockJson = { key: "value" };
|
||||
const mockResponse = new Response(JSON.stringify(mockJson));
|
||||
const result = await getResponseBody(mockResponse);
|
||||
expect(result).toEqual(mockJson);
|
||||
});
|
||||
|
||||
it("should handle empty response", async () => {
|
||||
const mockResponse = new Response("");
|
||||
const result = await getResponseBody(mockResponse);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle non-JSON response", async () => {
|
||||
const mockResponse = new Response("invalid json");
|
||||
const result = await getResponseBody(mockResponse);
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
reason: "non-json",
|
||||
statusCode: 200,
|
||||
rawBody: "invalid json",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
53
skyvern-ts/client/tests/unit/fetcher/makeRequest.test.ts
Normal file
53
skyvern-ts/client/tests/unit/fetcher/makeRequest.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { makeRequest } from "../../../src/core/fetcher/makeRequest";
|
||||
|
||||
describe("Test makeRequest", () => {
|
||||
const mockPostUrl = "https://httpbin.org/post";
|
||||
const mockGetUrl = "https://httpbin.org/get";
|
||||
const mockHeaders = { "Content-Type": "application/json" };
|
||||
const mockBody = JSON.stringify({ key: "value" });
|
||||
|
||||
let mockFetch: import("vitest").Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch = vi.fn();
|
||||
mockFetch.mockResolvedValue(new Response(JSON.stringify({ test: "successful" }), { status: 200 }));
|
||||
});
|
||||
|
||||
it("should handle POST request correctly", async () => {
|
||||
const response = await makeRequest(mockFetch, mockPostUrl, "POST", mockHeaders, mockBody);
|
||||
const responseBody = await response.json();
|
||||
expect(responseBody).toEqual({ test: "successful" });
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const [calledUrl, calledOptions] = mockFetch.mock.calls[0];
|
||||
expect(calledUrl).toBe(mockPostUrl);
|
||||
expect(calledOptions).toEqual(
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: mockHeaders,
|
||||
body: mockBody,
|
||||
credentials: undefined,
|
||||
}),
|
||||
);
|
||||
expect(calledOptions.signal).toBeDefined();
|
||||
expect(calledOptions.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it("should handle GET request correctly", async () => {
|
||||
const response = await makeRequest(mockFetch, mockGetUrl, "GET", mockHeaders, undefined);
|
||||
const responseBody = await response.json();
|
||||
expect(responseBody).toEqual({ test: "successful" });
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const [calledUrl, calledOptions] = mockFetch.mock.calls[0];
|
||||
expect(calledUrl).toBe(mockGetUrl);
|
||||
expect(calledOptions).toEqual(
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
headers: mockHeaders,
|
||||
body: undefined,
|
||||
credentials: undefined,
|
||||
}),
|
||||
);
|
||||
expect(calledOptions.signal).toBeDefined();
|
||||
expect(calledOptions.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
});
|
||||
250
skyvern-ts/client/tests/unit/fetcher/requestWithRetries.test.ts
Normal file
250
skyvern-ts/client/tests/unit/fetcher/requestWithRetries.test.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { requestWithRetries } from "../../../src/core/fetcher/requestWithRetries";
|
||||
|
||||
describe("requestWithRetries", () => {
|
||||
let mockFetch: import("vitest").Mock;
|
||||
let originalMathRandom: typeof Math.random;
|
||||
let setTimeoutSpy: import("vitest").MockInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch = vi.fn();
|
||||
originalMathRandom = Math.random;
|
||||
|
||||
// Mock Math.random for consistent jitter
|
||||
Math.random = vi.fn(() => 0.5);
|
||||
|
||||
vi.useFakeTimers({
|
||||
toFake: [
|
||||
"setTimeout",
|
||||
"clearTimeout",
|
||||
"setInterval",
|
||||
"clearInterval",
|
||||
"setImmediate",
|
||||
"clearImmediate",
|
||||
"Date",
|
||||
"performance",
|
||||
"requestAnimationFrame",
|
||||
"cancelAnimationFrame",
|
||||
"requestIdleCallback",
|
||||
"cancelIdleCallback",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Math.random = originalMathRandom;
|
||||
vi.clearAllMocks();
|
||||
vi.clearAllTimers();
|
||||
});
|
||||
|
||||
it("should retry on retryable status codes", async () => {
|
||||
setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => {
|
||||
process.nextTick(callback);
|
||||
return null as any;
|
||||
});
|
||||
|
||||
const retryableStatuses = [408, 429, 500, 502];
|
||||
let callCount = 0;
|
||||
|
||||
mockFetch.mockImplementation(async () => {
|
||||
if (callCount < retryableStatuses.length) {
|
||||
return new Response("", { status: retryableStatuses[callCount++] });
|
||||
}
|
||||
return new Response("", { status: 200 });
|
||||
});
|
||||
|
||||
const responsePromise = requestWithRetries(() => mockFetch(), retryableStatuses.length);
|
||||
await vi.runAllTimersAsync();
|
||||
const response = await responsePromise;
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(retryableStatuses.length + 1);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("should respect maxRetries limit", async () => {
|
||||
setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => {
|
||||
process.nextTick(callback);
|
||||
return null as any;
|
||||
});
|
||||
|
||||
const maxRetries = 2;
|
||||
mockFetch.mockResolvedValue(new Response("", { status: 500 }));
|
||||
|
||||
const responsePromise = requestWithRetries(() => mockFetch(), maxRetries);
|
||||
await vi.runAllTimersAsync();
|
||||
const response = await responsePromise;
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(maxRetries + 1);
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
it("should not retry on success status codes", async () => {
|
||||
setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => {
|
||||
process.nextTick(callback);
|
||||
return null as any;
|
||||
});
|
||||
|
||||
const successStatuses = [200, 201, 202];
|
||||
|
||||
for (const status of successStatuses) {
|
||||
mockFetch.mockReset();
|
||||
setTimeoutSpy.mockClear();
|
||||
mockFetch.mockResolvedValueOnce(new Response("", { status }));
|
||||
|
||||
const responsePromise = requestWithRetries(() => mockFetch(), 3);
|
||||
await vi.runAllTimersAsync();
|
||||
await responsePromise;
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
expect(setTimeoutSpy).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("should apply correct exponential backoff with jitter", async () => {
|
||||
setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => {
|
||||
process.nextTick(callback);
|
||||
return null as any;
|
||||
});
|
||||
|
||||
mockFetch.mockResolvedValue(new Response("", { status: 500 }));
|
||||
const maxRetries = 3;
|
||||
const expectedDelays = [1000, 2000, 4000];
|
||||
|
||||
const responsePromise = requestWithRetries(() => mockFetch(), maxRetries);
|
||||
await vi.runAllTimersAsync();
|
||||
await responsePromise;
|
||||
|
||||
// Verify setTimeout calls
|
||||
expect(setTimeoutSpy).toHaveBeenCalledTimes(expectedDelays.length);
|
||||
|
||||
expectedDelays.forEach((delay, index) => {
|
||||
expect(setTimeoutSpy).toHaveBeenNthCalledWith(index + 1, expect.any(Function), delay);
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(maxRetries + 1);
|
||||
});
|
||||
|
||||
it("should handle concurrent retries independently", async () => {
|
||||
setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => {
|
||||
process.nextTick(callback);
|
||||
return null as any;
|
||||
});
|
||||
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(new Response("", { status: 500 }))
|
||||
.mockResolvedValueOnce(new Response("", { status: 500 }))
|
||||
.mockResolvedValueOnce(new Response("", { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response("", { status: 200 }));
|
||||
|
||||
const promise1 = requestWithRetries(() => mockFetch(), 1);
|
||||
const promise2 = requestWithRetries(() => mockFetch(), 1);
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
const [response1, response2] = await Promise.all([promise1, promise2]);
|
||||
|
||||
expect(response1.status).toBe(200);
|
||||
expect(response2.status).toBe(200);
|
||||
});
|
||||
|
||||
it("should respect retry-after header with seconds value", async () => {
|
||||
setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => {
|
||||
process.nextTick(callback);
|
||||
return null as any;
|
||||
});
|
||||
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
new Response("", {
|
||||
status: 429,
|
||||
headers: new Headers({ "retry-after": "5" }),
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response("", { status: 200 }));
|
||||
|
||||
const responsePromise = requestWithRetries(() => mockFetch(), 1);
|
||||
await vi.runAllTimersAsync();
|
||||
const response = await responsePromise;
|
||||
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 5000); // 5 seconds = 5000ms
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("should respect retry-after header with HTTP date value", async () => {
|
||||
setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => {
|
||||
process.nextTick(callback);
|
||||
return null as any;
|
||||
});
|
||||
|
||||
const futureDate = new Date(Date.now() + 3000); // 3 seconds from now
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
new Response("", {
|
||||
status: 429,
|
||||
headers: new Headers({ "retry-after": futureDate.toUTCString() }),
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response("", { status: 200 }));
|
||||
|
||||
const responsePromise = requestWithRetries(() => mockFetch(), 1);
|
||||
await vi.runAllTimersAsync();
|
||||
const response = await responsePromise;
|
||||
|
||||
// Should use the date-based delay (approximately 3000ms, but with jitter)
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), expect.any(Number));
|
||||
const actualDelay = setTimeoutSpy.mock.calls[0][1];
|
||||
expect(actualDelay).toBeGreaterThan(2000);
|
||||
expect(actualDelay).toBeLessThan(4000);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("should respect x-ratelimit-reset header", async () => {
|
||||
setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => {
|
||||
process.nextTick(callback);
|
||||
return null as any;
|
||||
});
|
||||
|
||||
const resetTime = Math.floor((Date.now() + 4000) / 1000); // 4 seconds from now in Unix timestamp
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
new Response("", {
|
||||
status: 429,
|
||||
headers: new Headers({ "x-ratelimit-reset": resetTime.toString() }),
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response("", { status: 200 }));
|
||||
|
||||
const responsePromise = requestWithRetries(() => mockFetch(), 1);
|
||||
await vi.runAllTimersAsync();
|
||||
const response = await responsePromise;
|
||||
|
||||
// Should use the x-ratelimit-reset delay (approximately 4000ms, but with positive jitter)
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), expect.any(Number));
|
||||
const actualDelay = setTimeoutSpy.mock.calls[0][1];
|
||||
expect(actualDelay).toBeGreaterThan(3000);
|
||||
expect(actualDelay).toBeLessThan(6000);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("should cap delay at MAX_RETRY_DELAY for large header values", async () => {
|
||||
setTimeoutSpy = vi.spyOn(global, "setTimeout").mockImplementation((callback: (args: void) => void) => {
|
||||
process.nextTick(callback);
|
||||
return null as any;
|
||||
});
|
||||
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
new Response("", {
|
||||
status: 429,
|
||||
headers: new Headers({ "retry-after": "120" }), // 120 seconds = 120000ms > MAX_RETRY_DELAY (60000ms)
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response("", { status: 200 }));
|
||||
|
||||
const responsePromise = requestWithRetries(() => mockFetch(), 1);
|
||||
await vi.runAllTimersAsync();
|
||||
const response = await responsePromise;
|
||||
|
||||
// Should be capped at MAX_RETRY_DELAY (60000ms) with jitter applied
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 60000); // Exactly MAX_RETRY_DELAY since jitter with 0.5 random keeps it at 60000
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
69
skyvern-ts/client/tests/unit/fetcher/signals.test.ts
Normal file
69
skyvern-ts/client/tests/unit/fetcher/signals.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { anySignal, getTimeoutSignal } from "../../../src/core/fetcher/signals";
|
||||
|
||||
describe("Test getTimeoutSignal", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("should return an object with signal and abortId", () => {
|
||||
const { signal, abortId } = getTimeoutSignal(1000);
|
||||
|
||||
expect(signal).toBeDefined();
|
||||
expect(abortId).toBeDefined();
|
||||
expect(signal).toBeInstanceOf(AbortSignal);
|
||||
expect(signal.aborted).toBe(false);
|
||||
});
|
||||
|
||||
it("should create a signal that aborts after the specified timeout", () => {
|
||||
const timeoutMs = 5000;
|
||||
const { signal } = getTimeoutSignal(timeoutMs);
|
||||
|
||||
expect(signal.aborted).toBe(false);
|
||||
|
||||
vi.advanceTimersByTime(timeoutMs - 1);
|
||||
expect(signal.aborted).toBe(false);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(signal.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test anySignal", () => {
|
||||
it("should return an AbortSignal", () => {
|
||||
const signal = anySignal(new AbortController().signal);
|
||||
expect(signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it("should abort when any of the input signals is aborted", () => {
|
||||
const controller1 = new AbortController();
|
||||
const controller2 = new AbortController();
|
||||
const signal = anySignal(controller1.signal, controller2.signal);
|
||||
|
||||
expect(signal.aborted).toBe(false);
|
||||
controller1.abort();
|
||||
expect(signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle an array of signals", () => {
|
||||
const controller1 = new AbortController();
|
||||
const controller2 = new AbortController();
|
||||
const signal = anySignal([controller1.signal, controller2.signal]);
|
||||
|
||||
expect(signal.aborted).toBe(false);
|
||||
controller2.abort();
|
||||
expect(signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("should abort immediately if one of the input signals is already aborted", () => {
|
||||
const controller1 = new AbortController();
|
||||
const controller2 = new AbortController();
|
||||
controller1.abort();
|
||||
|
||||
const signal = anySignal(controller1.signal, controller2.signal);
|
||||
expect(signal.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
1
skyvern-ts/client/tests/unit/fetcher/test-file.txt
Normal file
1
skyvern-ts/client/tests/unit/fetcher/test-file.txt
Normal file
@@ -0,0 +1 @@
|
||||
This is a test file!
|
||||
120
skyvern-ts/client/tests/unit/url/join.test.ts
Normal file
120
skyvern-ts/client/tests/unit/url/join.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { join } from "../../../src/core/url/index";
|
||||
|
||||
describe("join", () => {
|
||||
describe("basic functionality", () => {
|
||||
it("should return empty string for empty base", () => {
|
||||
expect(join("")).toBe("");
|
||||
expect(join("", "path")).toBe("");
|
||||
});
|
||||
|
||||
it("should handle single segment", () => {
|
||||
expect(join("base", "segment")).toBe("base/segment");
|
||||
expect(join("base/", "segment")).toBe("base/segment");
|
||||
expect(join("base", "/segment")).toBe("base/segment");
|
||||
expect(join("base/", "/segment")).toBe("base/segment");
|
||||
});
|
||||
|
||||
it("should handle multiple segments", () => {
|
||||
expect(join("base", "path1", "path2", "path3")).toBe("base/path1/path2/path3");
|
||||
expect(join("base/", "/path1/", "/path2/", "/path3/")).toBe("base/path1/path2/path3/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL handling", () => {
|
||||
it("should handle absolute URLs", () => {
|
||||
expect(join("https://example.com", "api", "v1")).toBe("https://example.com/api/v1");
|
||||
expect(join("https://example.com/", "/api/", "/v1/")).toBe("https://example.com/api/v1/");
|
||||
expect(join("https://example.com/base", "api", "v1")).toBe("https://example.com/base/api/v1");
|
||||
});
|
||||
|
||||
it("should preserve URL query parameters and fragments", () => {
|
||||
expect(join("https://example.com?query=1", "api")).toBe("https://example.com/api?query=1");
|
||||
expect(join("https://example.com#fragment", "api")).toBe("https://example.com/api#fragment");
|
||||
expect(join("https://example.com?query=1#fragment", "api")).toBe(
|
||||
"https://example.com/api?query=1#fragment",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle different protocols", () => {
|
||||
expect(join("http://example.com", "api")).toBe("http://example.com/api");
|
||||
expect(join("ftp://example.com", "files")).toBe("ftp://example.com/files");
|
||||
expect(join("ws://example.com", "socket")).toBe("ws://example.com/socket");
|
||||
});
|
||||
|
||||
it("should fallback to path joining for malformed URLs", () => {
|
||||
expect(join("not-a-url://", "path")).toBe("not-a-url:///path");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle empty segments", () => {
|
||||
expect(join("base", "", "path")).toBe("base/path");
|
||||
expect(join("base", null as any, "path")).toBe("base/path");
|
||||
expect(join("base", undefined as any, "path")).toBe("base/path");
|
||||
});
|
||||
|
||||
it("should handle segments with only slashes", () => {
|
||||
expect(join("base", "/", "path")).toBe("base/path");
|
||||
expect(join("base", "//", "path")).toBe("base/path");
|
||||
});
|
||||
|
||||
it("should handle base paths with trailing slashes", () => {
|
||||
expect(join("base/", "path")).toBe("base/path");
|
||||
});
|
||||
|
||||
it("should handle complex nested paths", () => {
|
||||
expect(join("api/v1/", "/users/", "/123/", "/profile")).toBe("api/v1/users/123/profile");
|
||||
});
|
||||
});
|
||||
|
||||
describe("real-world scenarios", () => {
|
||||
it("should handle API endpoint construction", () => {
|
||||
const baseUrl = "https://api.example.com/v1";
|
||||
expect(join(baseUrl, "users", "123", "posts")).toBe("https://api.example.com/v1/users/123/posts");
|
||||
});
|
||||
|
||||
it("should handle file path construction", () => {
|
||||
expect(join("/var/www", "html", "assets", "images")).toBe("/var/www/html/assets/images");
|
||||
});
|
||||
|
||||
it("should handle relative path construction", () => {
|
||||
expect(join("../parent", "child", "grandchild")).toBe("../parent/child/grandchild");
|
||||
});
|
||||
|
||||
it("should handle Windows-style paths", () => {
|
||||
expect(join("C:\\Users", "Documents", "file.txt")).toBe("C:\\Users/Documents/file.txt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("performance scenarios", () => {
|
||||
it("should handle many segments efficiently", () => {
|
||||
const segments = Array(100).fill("segment");
|
||||
const result = join("base", ...segments);
|
||||
expect(result).toBe(`base/${segments.join("/")}`);
|
||||
});
|
||||
|
||||
it("should handle long URLs", () => {
|
||||
const longPath = "a".repeat(1000);
|
||||
expect(join("https://example.com", longPath)).toBe(`https://example.com/${longPath}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("trailing slash preservation", () => {
|
||||
it("should preserve trailing slash on final result when base has trailing slash and no segments", () => {
|
||||
expect(join("https://api.example.com/")).toBe("https://api.example.com/");
|
||||
expect(join("https://api.example.com/v1/")).toBe("https://api.example.com/v1/");
|
||||
});
|
||||
|
||||
it("should preserve trailing slash when last segment has trailing slash", () => {
|
||||
expect(join("https://api.example.com", "users/")).toBe("https://api.example.com/users/");
|
||||
expect(join("api/v1", "users/")).toBe("api/v1/users/");
|
||||
});
|
||||
|
||||
it("should preserve trailing slash with multiple segments where last has trailing slash", () => {
|
||||
expect(join("https://api.example.com", "v1", "collections/")).toBe(
|
||||
"https://api.example.com/v1/collections/",
|
||||
);
|
||||
expect(join("base", "path1", "path2/")).toBe("base/path1/path2/");
|
||||
});
|
||||
});
|
||||
});
|
||||
187
skyvern-ts/client/tests/unit/url/qs.test.ts
Normal file
187
skyvern-ts/client/tests/unit/url/qs.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { toQueryString } from "../../../src/core/url/index";
|
||||
|
||||
describe("Test qs toQueryString", () => {
|
||||
describe("Basic functionality", () => {
|
||||
it("should return empty string for null/undefined", () => {
|
||||
expect(toQueryString(null)).toBe("");
|
||||
expect(toQueryString(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("should return empty string for primitive values", () => {
|
||||
expect(toQueryString("hello")).toBe("");
|
||||
expect(toQueryString(42)).toBe("");
|
||||
expect(toQueryString(true)).toBe("");
|
||||
expect(toQueryString(false)).toBe("");
|
||||
});
|
||||
|
||||
it("should handle empty objects", () => {
|
||||
expect(toQueryString({})).toBe("");
|
||||
});
|
||||
|
||||
it("should handle simple key-value pairs", () => {
|
||||
const obj = { name: "John", age: 30 };
|
||||
expect(toQueryString(obj)).toBe("name=John&age=30");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Array handling", () => {
|
||||
it("should handle arrays with indices format (default)", () => {
|
||||
const obj = { items: ["a", "b", "c"] };
|
||||
expect(toQueryString(obj)).toBe("items%5B0%5D=a&items%5B1%5D=b&items%5B2%5D=c");
|
||||
});
|
||||
|
||||
it("should handle arrays with repeat format", () => {
|
||||
const obj = { items: ["a", "b", "c"] };
|
||||
expect(toQueryString(obj, { arrayFormat: "repeat" })).toBe("items=a&items=b&items=c");
|
||||
});
|
||||
|
||||
it("should handle empty arrays", () => {
|
||||
const obj = { items: [] };
|
||||
expect(toQueryString(obj)).toBe("");
|
||||
});
|
||||
|
||||
it("should handle arrays with mixed types", () => {
|
||||
const obj = { mixed: ["string", 42, true, false] };
|
||||
expect(toQueryString(obj)).toBe("mixed%5B0%5D=string&mixed%5B1%5D=42&mixed%5B2%5D=true&mixed%5B3%5D=false");
|
||||
});
|
||||
|
||||
it("should handle arrays with objects", () => {
|
||||
const obj = { users: [{ name: "John" }, { name: "Jane" }] };
|
||||
expect(toQueryString(obj)).toBe("users%5B0%5D%5Bname%5D=John&users%5B1%5D%5Bname%5D=Jane");
|
||||
});
|
||||
|
||||
it("should handle arrays with objects in repeat format", () => {
|
||||
const obj = { users: [{ name: "John" }, { name: "Jane" }] };
|
||||
expect(toQueryString(obj, { arrayFormat: "repeat" })).toBe("users%5Bname%5D=John&users%5Bname%5D=Jane");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Nested objects", () => {
|
||||
it("should handle nested objects", () => {
|
||||
const obj = { user: { name: "John", age: 30 } };
|
||||
expect(toQueryString(obj)).toBe("user%5Bname%5D=John&user%5Bage%5D=30");
|
||||
});
|
||||
|
||||
it("should handle deeply nested objects", () => {
|
||||
const obj = { user: { profile: { name: "John", settings: { theme: "dark" } } } };
|
||||
expect(toQueryString(obj)).toBe(
|
||||
"user%5Bprofile%5D%5Bname%5D=John&user%5Bprofile%5D%5Bsettings%5D%5Btheme%5D=dark",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle empty nested objects", () => {
|
||||
const obj = { user: {} };
|
||||
expect(toQueryString(obj)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Encoding", () => {
|
||||
it("should encode by default", () => {
|
||||
const obj = { name: "John Doe", email: "john@example.com" };
|
||||
expect(toQueryString(obj)).toBe("name=John%20Doe&email=john%40example.com");
|
||||
});
|
||||
|
||||
it("should not encode when encode is false", () => {
|
||||
const obj = { name: "John Doe", email: "john@example.com" };
|
||||
expect(toQueryString(obj, { encode: false })).toBe("name=John Doe&email=john@example.com");
|
||||
});
|
||||
|
||||
it("should encode special characters in keys", () => {
|
||||
const obj = { "user name": "John", "email[primary]": "john@example.com" };
|
||||
expect(toQueryString(obj)).toBe("user%20name=John&email%5Bprimary%5D=john%40example.com");
|
||||
});
|
||||
|
||||
it("should not encode special characters in keys when encode is false", () => {
|
||||
const obj = { "user name": "John", "email[primary]": "john@example.com" };
|
||||
expect(toQueryString(obj, { encode: false })).toBe("user name=John&email[primary]=john@example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mixed scenarios", () => {
|
||||
it("should handle complex nested structures", () => {
|
||||
const obj = {
|
||||
filters: {
|
||||
status: ["active", "pending"],
|
||||
category: {
|
||||
type: "electronics",
|
||||
subcategories: ["phones", "laptops"],
|
||||
},
|
||||
},
|
||||
sort: { field: "name", direction: "asc" },
|
||||
};
|
||||
expect(toQueryString(obj)).toBe(
|
||||
"filters%5Bstatus%5D%5B0%5D=active&filters%5Bstatus%5D%5B1%5D=pending&filters%5Bcategory%5D%5Btype%5D=electronics&filters%5Bcategory%5D%5Bsubcategories%5D%5B0%5D=phones&filters%5Bcategory%5D%5Bsubcategories%5D%5B1%5D=laptops&sort%5Bfield%5D=name&sort%5Bdirection%5D=asc",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle complex nested structures with repeat format", () => {
|
||||
const obj = {
|
||||
filters: {
|
||||
status: ["active", "pending"],
|
||||
category: {
|
||||
type: "electronics",
|
||||
subcategories: ["phones", "laptops"],
|
||||
},
|
||||
},
|
||||
sort: { field: "name", direction: "asc" },
|
||||
};
|
||||
expect(toQueryString(obj, { arrayFormat: "repeat" })).toBe(
|
||||
"filters%5Bstatus%5D=active&filters%5Bstatus%5D=pending&filters%5Bcategory%5D%5Btype%5D=electronics&filters%5Bcategory%5D%5Bsubcategories%5D=phones&filters%5Bcategory%5D%5Bsubcategories%5D=laptops&sort%5Bfield%5D=name&sort%5Bdirection%5D=asc",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle arrays with null/undefined values", () => {
|
||||
const obj = { items: ["a", null, "c", undefined, "e"] };
|
||||
expect(toQueryString(obj)).toBe("items%5B0%5D=a&items%5B1%5D=&items%5B2%5D=c&items%5B4%5D=e");
|
||||
});
|
||||
|
||||
it("should handle objects with null/undefined values", () => {
|
||||
const obj = { name: "John", age: null, email: undefined, active: true };
|
||||
expect(toQueryString(obj)).toBe("name=John&age=&active=true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge cases", () => {
|
||||
it("should handle numeric keys", () => {
|
||||
const obj = { "0": "zero", "1": "one" };
|
||||
expect(toQueryString(obj)).toBe("0=zero&1=one");
|
||||
});
|
||||
|
||||
it("should handle boolean values in objects", () => {
|
||||
const obj = { enabled: true, disabled: false };
|
||||
expect(toQueryString(obj)).toBe("enabled=true&disabled=false");
|
||||
});
|
||||
|
||||
it("should handle empty strings", () => {
|
||||
const obj = { name: "", description: "test" };
|
||||
expect(toQueryString(obj)).toBe("name=&description=test");
|
||||
});
|
||||
|
||||
it("should handle zero values", () => {
|
||||
const obj = { count: 0, price: 0.0 };
|
||||
expect(toQueryString(obj)).toBe("count=0&price=0");
|
||||
});
|
||||
|
||||
it("should handle arrays with empty strings", () => {
|
||||
const obj = { items: ["a", "", "c"] };
|
||||
expect(toQueryString(obj)).toBe("items%5B0%5D=a&items%5B1%5D=&items%5B2%5D=c");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Options combinations", () => {
|
||||
it("should respect both arrayFormat and encode options", () => {
|
||||
const obj = { items: ["a & b", "c & d"] };
|
||||
expect(toQueryString(obj, { arrayFormat: "repeat", encode: false })).toBe("items=a & b&items=c & d");
|
||||
});
|
||||
|
||||
it("should use default options when none provided", () => {
|
||||
const obj = { items: ["a", "b"] };
|
||||
expect(toQueryString(obj)).toBe("items%5B0%5D=a&items%5B1%5D=b");
|
||||
});
|
||||
|
||||
it("should merge provided options with defaults", () => {
|
||||
const obj = { items: ["a", "b"], name: "John Doe" };
|
||||
expect(toQueryString(obj, { encode: false })).toBe("items[0]=a&items[1]=b&name=John Doe");
|
||||
});
|
||||
});
|
||||
});
|
||||
0
skyvern-ts/client/tests/wire/.gitkeep
Normal file
0
skyvern-ts/client/tests/wire/.gitkeep
Normal file
2426
skyvern-ts/client/tests/wire/main.test.ts
Normal file
2426
skyvern-ts/client/tests/wire/main.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
44
skyvern-ts/client/tests/wire/scripts.test.ts
Normal file
44
skyvern-ts/client/tests/wire/scripts.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// This file was auto-generated by Fern from our API Definition.
|
||||
|
||||
import * as Skyvern from "../../src/api/index";
|
||||
import { SkyvernClient } from "../../src/Client";
|
||||
import { mockServerPool } from "../mock-server/MockServerPool";
|
||||
|
||||
describe("Scripts", () => {
|
||||
test("runScript (1)", async () => {
|
||||
const server = mockServerPool.createServer();
|
||||
const client = new SkyvernClient({ xApiKey: "test", apiKey: "test", environment: server.baseUrl });
|
||||
|
||||
const rawResponseBody = { key: "value" };
|
||||
server
|
||||
.mockEndpoint()
|
||||
.post("/v1/scripts/s_abc123/run")
|
||||
.respondWith()
|
||||
.statusCode(200)
|
||||
.jsonBody(rawResponseBody)
|
||||
.build();
|
||||
|
||||
const response = await client.scripts.runScript("s_abc123");
|
||||
expect(response).toEqual({
|
||||
key: "value",
|
||||
});
|
||||
});
|
||||
|
||||
test("runScript (2)", async () => {
|
||||
const server = mockServerPool.createServer();
|
||||
const client = new SkyvernClient({ xApiKey: "test", apiKey: "test", environment: server.baseUrl });
|
||||
|
||||
const rawResponseBody = { key: "value" };
|
||||
server
|
||||
.mockEndpoint()
|
||||
.post("/v1/scripts/script_id/run")
|
||||
.respondWith()
|
||||
.statusCode(422)
|
||||
.jsonBody(rawResponseBody)
|
||||
.build();
|
||||
|
||||
await expect(async () => {
|
||||
return await client.scripts.runScript("script_id");
|
||||
}).rejects.toThrow(Skyvern.UnprocessableEntityError);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user