PromucFlow_constructor/app/client/src/ce/utils/serviceWorkerUtils.test.ts
Diljit 4cb6ff03f9
chore: Prefetch module apis in service worker (#34003)
## Description
This PR has the following changes
- Modify the prefetch api cache strategy to handle multiple prefetch
requests.
- Convert the service worker files from js to ts
- Code splitting of CE and EE for service worker utils to handle EE
specific changes
- Jest test cases for the updated logic


Fixes #`Issue Number`  
_or_  
Fixes `Issue URL`
> [!WARNING]  
> _If no issue exists, please create an issue first, and check with the
maintainers if the issue is valid._

## Automation

/ok-to-test tags="@tag.All"

### 🔍 Cypress test results
<!-- This is an auto-generated comment: Cypress test results  -->
> [!TIP]
> 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉
> Workflow run:
<https://github.com/appsmithorg/appsmith/actions/runs/9511154598>
> Commit: 785197e27873e71ed457f785a73d4ea57f379213
> Cypress dashboard url: <a
href="https://internal.appsmith.com/app/cypress-dashboard/rundetails-65890b3c81d7400d08fa9ee5?branch=master&workflowId=9511154598&attempt=1"
target="_blank">Click here!</a>

<!-- end of auto-generated comment: Cypress test results  -->





















## Communication
Should the DevRel and Marketing teams inform users about this change?
- [ ] Yes
- [ ] No


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Enhanced API request handling with new utility functions and caching
strategies for service worker operations.
- Updated service worker configuration to TypeScript for improved type
safety and maintainability.
- Added type definitions for `node-fetch` to support new service worker
functionalities.

- **Refactor**
- Consolidated `view` and `edit` endpoint URL construction in
`ConsolidatedPageLoadApi` for better code maintainability.

- **Chores**
- Updated dependencies in `package.json` for better development
experience and compatibility.
- Added tests for new service worker utility functions to ensure
reliability and correctness.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2024-06-14 16:30:23 +05:30

625 lines
21 KiB
TypeScript

import { APP_MODE } from "entities/App";
import type { TApplicationParams } from "./serviceWorkerUtils";
import {
matchBuilderPath,
matchViewerPath,
getSearchQuery,
getConsolidatedApiPrefetchRequest,
getApplicationParamsFromUrl,
getPrefetchRequests,
PrefetchApiService,
} from "./serviceWorkerUtils";
import { Mutex } from "async-mutex";
import { Request as NFRequest, Response as NFResponse } from "node-fetch";
(global as any).fetch = jest.fn() as jest.Mock;
(global as any).caches = {
open: jest.fn(),
delete: jest.fn(),
};
describe("serviceWorkerUtils", () => {
describe("matchBuilderPath", () => {
it("should match the standard builder path", () => {
const pathName = "/app/applicationSlug/pageSlug-123/edit";
const options = { end: false };
const result = matchBuilderPath(pathName, options);
expect(result).toBeTruthy();
if (result) {
expect(result.params).toHaveProperty("applicationSlug");
expect(result.params).toHaveProperty("pageSlug");
expect(result.params).toHaveProperty("pageId", "123");
} else {
fail("Expected result to be truthy");
}
});
it("should match the standard builder path for alphanumeric pageId", () => {
const pathName =
"/app/applicationSlug/pageSlug-6616733a6e70274710f21a07/edit";
const options = { end: false };
const result = matchBuilderPath(pathName, options);
expect(result).toBeTruthy();
if (result) {
expect(result.params).toHaveProperty("applicationSlug");
expect(result.params).toHaveProperty("pageSlug");
expect(result.params).toHaveProperty(
"pageId",
"6616733a6e70274710f21a07",
);
} else {
fail("Expected result to be truthy");
}
});
it("should match the custom builder path", () => {
const pathName = "/app/customSlug-custom-456/edit";
const options = { end: false };
const result = matchBuilderPath(pathName, options);
expect(result).toBeTruthy();
if (result) {
expect(result.params).toHaveProperty("customSlug");
expect(result.params).toHaveProperty("pageId", "456");
} else {
fail("Expected result to be truthy");
}
});
it("should match the deprecated builder path", () => {
const pathName = "/applications/appId123/pages/456/edit";
const options = { end: false };
const result = matchBuilderPath(pathName, options);
expect(result).toBeTruthy();
if (result) {
expect(result.params).toHaveProperty("applicationId", "appId123");
expect(result.params).toHaveProperty("pageId", "456");
} else {
fail("Expected result to be truthy");
}
});
it("should not match incorrect builder path", () => {
const pathName = "/app/applicationSlug/nonMatching-123";
const options = { end: false };
const result = matchBuilderPath(pathName, options);
expect(result).toBeFalsy();
});
it("should not match when no pageId is present", () => {
const pathName = "/app/applicationSlug/pageSlug-edit";
const options = { end: false };
const result = matchBuilderPath(pathName, options);
expect(result).toBeFalsy();
});
it("should match when the path is edit widgets", () => {
const pathName =
"/app/applicationSlug/pageSlug-123/edit/widgets/t36hb2zukr";
const options = { end: false };
const result = matchBuilderPath(pathName, options);
if (result) {
expect(result.params).toHaveProperty("applicationSlug");
expect(result.params).toHaveProperty("pageSlug");
expect(result.params).toHaveProperty("pageId", "123");
} else {
fail("Expected result to be truthy");
}
});
});
describe("matchViewerPath", () => {
it("should match the standard viewer path", () => {
const pathName = "/app/applicationSlug/pageSlug-123";
const result = matchViewerPath(pathName);
expect(result).toBeTruthy();
if (result) {
expect(result.params).toHaveProperty("applicationSlug");
expect(result.params).toHaveProperty("pageSlug");
expect(result.params).toHaveProperty("pageId", "123");
} else {
fail("Expected result to be truthy");
}
});
it("should match the custom viewer path", () => {
const pathName = "/app/customSlug-custom-456";
const result = matchViewerPath(pathName);
expect(result).toBeTruthy();
if (result) {
expect(result.params).toHaveProperty("customSlug");
expect(result.params).toHaveProperty("pageId", "456");
} else {
fail("Expected result to be truthy");
}
});
it("should match the deprecated viewer path", () => {
const pathName = "/applications/appId123/pages/456";
const result = matchViewerPath(pathName);
expect(result).toBeTruthy();
if (result) {
expect(result.params).toHaveProperty("applicationId", "appId123");
expect(result.params).toHaveProperty("pageId", "456");
} else {
fail("Expected result to be truthy");
}
});
it("should not match when no pageId is present", () => {
const pathName = "/app/applicationSlug/pageSlug";
const result = matchViewerPath(pathName);
expect(result).toBeFalsy();
});
});
describe("getSearchQuery", () => {
it("should return the search query from the URL", () => {
const search = "?key=value";
const key = "key";
const result = getSearchQuery(search, key);
expect(result).toEqual("value");
});
it("should return an empty string if the key is not present in the URL", () => {
const search = "?key=value";
const key = "invalid";
const result = getSearchQuery(search, key);
expect(result).toEqual("");
});
it("should return an empty string if the search query is empty", () => {
const search = "";
const key = "key";
const result = getSearchQuery(search, key);
expect(result).toEqual("");
});
});
describe("getApplicationParamsFromUrl", () => {
it("should parse URL and return correct params for builder path", () => {
const url = new URL(
"https://app.appsmith.com/app/my-app/page-123/edit?branch=main",
);
const expectedParams: TApplicationParams = {
origin: "https://app.appsmith.com",
pageId: "123",
applicationId: undefined,
branchName: "main",
appMode: APP_MODE.EDIT,
};
expect(getApplicationParamsFromUrl(url)).toEqual(expectedParams);
});
it("should parse URL and return correct params for viewer path", () => {
const url = new URL(
"https://app.appsmith.com/app/my-app/page-123?branch=main",
);
const expectedParams: TApplicationParams = {
origin: "https://app.appsmith.com",
pageId: "123",
applicationId: undefined,
branchName: "main",
appMode: APP_MODE.PUBLISHED,
};
expect(getApplicationParamsFromUrl(url)).toEqual(expectedParams);
});
it("should return null if the path does not match any pattern", () => {
const url = new URL("https://app.appsmith.com/invalid/path?branch=main");
expect(getApplicationParamsFromUrl(url)).toBeNull();
});
it("should parse deprecated builder path and return correct params", () => {
const url = new URL(
"https://app.appsmith.com/applications/app-123/pages/page-123/edit?branch=main",
);
const expectedParams: TApplicationParams = {
origin: "https://app.appsmith.com",
pageId: "page-123",
applicationId: "app-123",
branchName: "main",
appMode: APP_MODE.EDIT,
};
expect(getApplicationParamsFromUrl(url)).toEqual(expectedParams);
});
it("should parse deprecated viewer path and return correct params", () => {
const url = new URL(
"https://app.appsmith.com/applications/app-123/pages/page-123?branch=main",
);
const expectedParams: TApplicationParams = {
origin: "https://app.appsmith.com",
pageId: "page-123",
applicationId: "app-123",
branchName: "main",
appMode: APP_MODE.PUBLISHED,
};
expect(getApplicationParamsFromUrl(url)).toEqual(expectedParams);
});
it("should parse custom builder path and return correct params", () => {
const url = new URL(
"https://app.appsmith.com/app/custom-app-123/edit?branch=main",
);
const expectedParams: TApplicationParams = {
origin: "https://app.appsmith.com",
pageId: "123",
applicationId: undefined,
branchName: "main",
appMode: APP_MODE.EDIT,
};
expect(getApplicationParamsFromUrl(url)).toEqual(expectedParams);
});
it("should parse custom viewer path and return correct params", () => {
const url = new URL(
"https://app.appsmith.com/app/custom-app-123?branch=main",
);
const expectedParams: TApplicationParams = {
origin: "https://app.appsmith.com",
pageId: "123",
applicationId: undefined,
branchName: "main",
appMode: APP_MODE.PUBLISHED,
};
expect(getApplicationParamsFromUrl(url)).toEqual(expectedParams);
});
it("should parse URL and return params with empty branch name if branch query param is not present", () => {
const url = new URL("https://app.appsmith.com/app/my-app/page-123/edit");
const expectedParams: TApplicationParams = {
origin: "https://app.appsmith.com",
pageId: "123",
applicationId: undefined,
branchName: "",
appMode: APP_MODE.EDIT,
};
expect(getApplicationParamsFromUrl(url)).toEqual(expectedParams);
});
});
describe("getConsolidatedApiPrefetchRequest", () => {
beforeAll(() => {
(global as any).Request = NFRequest;
});
it("should return null if pageId is not provided", () => {
const params: TApplicationParams = {
origin: "https://app.appsmith.com",
branchName: "main",
appMode: APP_MODE.EDIT,
};
expect(getConsolidatedApiPrefetchRequest(params)).toBeNull();
});
it("should create request for EDIT mode with applicationId", () => {
const params: TApplicationParams = {
origin: "https://app.appsmith.com",
pageId: "page123",
applicationId: "app123",
branchName: "main",
appMode: APP_MODE.EDIT,
};
const request = getConsolidatedApiPrefetchRequest(params);
expect(request).toBeInstanceOf(Request);
expect(request?.url).toBe(
`https://app.appsmith.com/api/v1/consolidated-api/edit?defaultPageId=page123&applicationId=app123`,
);
expect(request?.method).toBe("GET");
expect(request?.headers.get("Branchname")).toBe("main");
});
it("should create request for PUBLISHED mode with applicationId", () => {
const params: TApplicationParams = {
origin: "https://app.appsmith.com",
pageId: "page123",
applicationId: "app123",
branchName: "main",
appMode: APP_MODE.PUBLISHED,
};
const request = getConsolidatedApiPrefetchRequest(params);
expect(request).toBeInstanceOf(Request);
expect(request?.url).toBe(
`https://app.appsmith.com/api/v1/consolidated-api/view?defaultPageId=page123&applicationId=app123`,
);
expect(request?.method).toBe("GET");
expect(request?.headers.get("Branchname")).toBe("main");
});
it("should create request for EDIT mode without applicationId", () => {
const params: TApplicationParams = {
origin: "https://app.appsmith.com",
pageId: "page123",
branchName: "main",
appMode: APP_MODE.EDIT,
};
const request = getConsolidatedApiPrefetchRequest(params);
expect(request).toBeInstanceOf(Request);
expect(request?.url).toBe(
`https://app.appsmith.com/api/v1/consolidated-api/edit?defaultPageId=page123`,
);
expect(request?.method).toBe("GET");
expect(request?.headers.get("Branchname")).toBe("main");
});
it("should create request for PUBLISHED mode without applicationId", () => {
const params: TApplicationParams = {
origin: "https://app.appsmith.com",
pageId: "page123",
branchName: "main",
appMode: APP_MODE.PUBLISHED,
};
const request = getConsolidatedApiPrefetchRequest(params);
expect(request).toBeInstanceOf(Request);
expect(request?.url).toBe(
`https://app.appsmith.com/api/v1/consolidated-api/view?defaultPageId=page123`,
);
expect(request?.method).toBe("GET");
expect(request?.headers.get("Branchname")).toBe("main");
});
it("should return null for an unknown app mode", () => {
const params: TApplicationParams = {
origin: "https://app.appsmith.com",
pageId: "page123",
branchName: "main",
appMode: "UNKNOWN" as APP_MODE,
};
expect(getConsolidatedApiPrefetchRequest(params)).toBeNull();
});
});
describe("getPrefetchRequests", () => {
it("should return prefetch requests with consolidated api request", () => {
const params: TApplicationParams = {
origin: "https://app.appsmith.com",
branchName: "main",
appMode: APP_MODE.EDIT,
pageId: "page123",
};
const requests = getPrefetchRequests(params);
expect(requests).toHaveLength(1);
const [consolidatedAPIRequest] = requests;
expect(consolidatedAPIRequest).toBeInstanceOf(Request);
expect(consolidatedAPIRequest?.url).toBe(
`https://app.appsmith.com/api/v1/consolidated-api/edit?defaultPageId=page123`,
);
expect(consolidatedAPIRequest?.method).toBe("GET");
expect(consolidatedAPIRequest?.headers.get("Branchname")).toBe("main");
});
});
describe("PrefetchApiService", () => {
let prefetchApiService: PrefetchApiService;
let mockCache: any;
beforeEach(() => {
prefetchApiService = new PrefetchApiService();
mockCache = {
put: jest.fn(),
match: jest.fn(),
delete: jest.fn(),
};
(global as any).caches.open.mockResolvedValue(mockCache);
(global as any).Request = NFRequest;
(global as any).Response = NFResponse;
});
afterEach(() => {
jest.clearAllMocks();
});
describe("getRequestKey", () => {
it("should return the correct request key", () => {
const request = new Request("https://app.appsmith.com", {
method: "GET",
});
request.headers.append("branchname", "main");
const key = prefetchApiService.getRequestKey(request);
expect(key).toBe("GET:https://app.appsmith.com/:branchname:main");
});
});
describe("aqcuireFetchMutex", () => {
it("should acquire a new mutex if not present", async () => {
const request = new Request("https://app.appsmith.com", {
method: "GET",
});
const acquireSpy = jest.spyOn(Mutex.prototype, "acquire");
await prefetchApiService.aqcuireFetchMutex(request);
expect(acquireSpy).toHaveBeenCalled();
});
it("should reuse existing mutex if present", async () => {
const request = new Request("https://app.appsmith.com", {
method: "GET",
});
const mutex = new Mutex();
prefetchApiService.prefetchFetchMutexMap.set(
prefetchApiService.getRequestKey(request),
mutex,
);
const acquireSpy = jest.spyOn(mutex, "acquire");
await prefetchApiService.aqcuireFetchMutex(request);
expect(acquireSpy).toHaveBeenCalled();
});
});
describe("waitForUnlock", () => {
it("should wait for the mutex to unlock if it exists", async () => {
const request = new Request("https://app.appsmith.com", {
method: "GET",
});
const mutex = new Mutex();
prefetchApiService.prefetchFetchMutexMap.set(
prefetchApiService.getRequestKey(request),
mutex,
);
const waitForUnlockSpy = jest.spyOn(mutex, "waitForUnlock");
await prefetchApiService.waitForUnlock(request);
expect(waitForUnlockSpy).toHaveBeenCalled();
});
it("should do nothing if the mutex does not exist", async () => {
const request = new Request("https://app.appsmith.com", {
method: "GET",
});
await expect(
prefetchApiService.waitForUnlock(request),
).resolves.not.toThrow();
});
});
describe("releaseFetchMutex", () => {
it("should release the mutex if it exists", () => {
const request = new Request("https://app.appsmith.com", {
method: "GET",
});
const mutex = new Mutex();
prefetchApiService.prefetchFetchMutexMap.set(
prefetchApiService.getRequestKey(request),
mutex,
);
const releaseSpy = jest.spyOn(mutex, "release");
prefetchApiService.releaseFetchMutex(request);
expect(releaseSpy).toHaveBeenCalled();
});
it("should do nothing if the mutex does not exist", () => {
const request = new Request("https://app.appsmith.com", {
method: "GET",
});
expect(() =>
prefetchApiService.releaseFetchMutex(request),
).not.toThrow();
});
});
describe("cacheApi", () => {
it("should acquire the mutex, fetch the request, cache the response, and release the mutex", async () => {
const request = new Request("https://app.appsmith.com/sdfsdf", {
method: "GET",
});
const response = new Response("Test response", {
status: 200,
statusText: "OK",
});
(global as any).fetch.mockResolvedValue(response);
const acquireSpy = jest.spyOn(Mutex.prototype, "acquire");
const releaseSpy = jest.spyOn(Mutex.prototype, "release");
await prefetchApiService.cacheApi(request);
expect(acquireSpy).toHaveBeenCalled();
expect((global as any).fetch).toHaveBeenCalledWith(request);
expect(mockCache.put).toHaveBeenCalledWith(
request,
expect.objectContaining({
status: 200,
statusText: "OK",
}),
);
expect(releaseSpy).toHaveBeenCalled();
});
it("should delete the cache and release the mutex if fetch fails", async () => {
const request = new Request("https://app.appsmith.com", {
method: "GET",
});
(global as any).fetch.mockRejectedValue(new Error("Fetch error"));
const acquireSpy = jest.spyOn(Mutex.prototype, "acquire");
const releaseSpy = jest.spyOn(Mutex.prototype, "release");
await prefetchApiService.cacheApi(request);
expect(acquireSpy).toHaveBeenCalled();
expect(mockCache.delete).toHaveBeenCalledWith(request);
expect(releaseSpy).toHaveBeenCalled();
});
});
describe("getCachedResponse", () => {
it("should wait for the mutex to unlock, get the cached response if valid, and delete it afterwards", async () => {
const request = new Request("https://app.appsmith.com", {
method: "GET",
});
const response = new Response("test response", {
headers: { date: new Date(Date.now() - 1000).toUTCString() },
});
mockCache.match.mockResolvedValue(response);
const mutex = new Mutex();
prefetchApiService.prefetchFetchMutexMap.set(
prefetchApiService.getRequestKey(request),
mutex,
);
const waitForUnlockSpy = jest.spyOn(Mutex.prototype, "waitForUnlock");
const cachedResponse =
await prefetchApiService.getCachedResponse(request);
expect(waitForUnlockSpy).toHaveBeenCalled();
expect(mockCache.match).toHaveBeenCalledWith(request);
expect(mockCache.delete).toHaveBeenCalledWith(request);
expect(cachedResponse).toBe(response);
});
it("should return null if the cache is invalid", async () => {
const request = new Request("https://app.appsmith.com", {
method: "GET",
});
const response = new Response("test response", {
headers: { date: new Date(Date.now() - 3 * 60 * 1000).toUTCString() },
});
mockCache.match.mockResolvedValue(response);
const cachedResponse =
await prefetchApiService.getCachedResponse(request);
expect(mockCache.match).toHaveBeenCalledWith(request);
expect(mockCache.delete).toHaveBeenCalledWith(request);
expect(cachedResponse).toBeNull();
});
it("should return null if there is no cached response", async () => {
const request = new Request("https://app.appsmith.com", {
method: "GET",
});
mockCache.match.mockResolvedValue(null);
const cachedResponse =
await prefetchApiService.getCachedResponse(request);
expect(mockCache.match).toHaveBeenCalledWith(request);
expect(cachedResponse).toBeNull();
});
});
});
});