Generate Fern TypeSscript SDK (#3785)
This commit is contained in:
committed by
GitHub
parent
d55b9637c4
commit
2062adac66
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user