-
Notifications
You must be signed in to change notification settings - Fork 100
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
refactor: Lock profiles during async operations (phase 1: filesystem) (…
…#3370) * move DeferredPromise to API Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com> * wip: AuthHandler Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com> * refactor: 'promptForAuthOnError' -> 'lockProfileOnAuthError' Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com> * wip: reload workspaces once reauthenticated - note: may need to throttle amount of times the reload is called, if you have a workspace with hundreds of folders it will make a list request for each one Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com> * wip: lock in other FS providers; fix update credentials logic Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com> * wip(tests): resolve multiple failing tests Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com> * tests: resolve failing ProfilesUtils cases Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com> * wip: rename AuthUtils method, update FS providers Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com> * refactor: Mutex class & use in AuthHandler; TSDoc - Adds a new `Mutex` class that wraps around a `DeferredPromise`. See the `@brief` for details. - Refactors the `AuthHandler` class to use a map of `Mutex` for each profile used in critical sections. - Added doc: `AuthHandler, DeferredPromise, Mutex` Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com> * add reloadActiveEditorForProfile and related logic Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com> * expose Mutex, fix failing tests Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com> * chore: fix unused warnings in test code Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com> * chore: fix lint and formatting errors - removed `no-return-await` lint rule since the extra microtask was removed in recent versions of Node, so there is now a benefit to using `return await` - add test `zowe.config.json` to prettierignore to avoid invalid JSON errors Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com> * tests: DeferredPromise, Mutex Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com> * chore: rename test file in API to match source file Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com> * remove unused import Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com> * refactor: use async-mutex instead of homemade mutex Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com> * refactor: move fs helper fns, isLocked -> isProfileLocked Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com> * wip: AuthHandler tests Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com> * wip: patch coverage for AuthHandler, DeferredPromise Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com> * refactor: lockedProfiles -> profileLocks, patch cov in ProfilesUtils Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com> * tests: FileManagement functions Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com> * remove unused var 'credsEntered' Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com> * refactor: save decorator for phase 2 (API changes) Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com> * tests: AuthHandler SSO login during auth prompt Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com> * add test for basic creds, move logic for updating trees Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com> * fix lint errors Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com> * tests: AuthHandler.updateTreeProvidersWithProfile Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com> * chore: add changelogs Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com> * fix: refresh resources if updated in 'Manage Profile' option Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com> * refactor: use enum for DeferredPromise status Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com> * refactor: use onProfileUpdate event to update tree providers Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com> * fix(AuthHandler): remove redundant includes check Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com> * refactor: combine nested ifs into single expression Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com> * refactor: remove return in AuthHandler.lockProfile Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com> * refactor: DeferredPromiseStatus.{Fulfilled -> Resolved} Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com> * refactor: Clean up auth prompt options, adjust interfaces Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com> * fix: add missing prompt; return expected boolean value Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com> * fix(ds): remove extra 401 message during fetch Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com> --------- Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com>
- Loading branch information
Showing
34 changed files
with
792 additions
and
142 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
159 changes: 159 additions & 0 deletions
159
packages/zowe-explorer-api/__tests__/__unit__/profiles/AuthHandler.unit.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,159 @@ | ||
/** | ||
* This program and the accompanying materials are made available under the terms of the | ||
* Eclipse Public License v2.0 which accompanies this distribution, and is available at | ||
* https://www.eclipse.org/legal/epl-v20.html | ||
* | ||
* SPDX-License-Identifier: EPL-2.0 | ||
* | ||
* Copyright Contributors to the Zowe Project. | ||
* | ||
*/ | ||
|
||
import { Mutex } from "async-mutex"; | ||
import { AuthHandler, Gui } from "../../../src"; | ||
import { FileManagement } from "../../../src/utils/FileManagement"; | ||
import { ImperativeError } from "@zowe/imperative"; | ||
import { AuthPromptParams } from "@zowe/zowe-explorer-api"; | ||
|
||
const TEST_PROFILE_NAME = "lpar.zosmf"; | ||
|
||
describe("AuthHandler.isProfileLocked", () => { | ||
it("returns true if the profile is locked", async () => { | ||
await AuthHandler.lockProfile(TEST_PROFILE_NAME); | ||
expect(AuthHandler.isProfileLocked(TEST_PROFILE_NAME)).toBe(true); | ||
AuthHandler.unlockProfile(TEST_PROFILE_NAME); | ||
}); | ||
|
||
it("returns false if the profile is not locked", async () => { | ||
expect(AuthHandler.isProfileLocked(TEST_PROFILE_NAME)).toBe(false); | ||
}); | ||
|
||
it("returns false if no mutex is present for the given profile", async () => { | ||
expect(AuthHandler.isProfileLocked("unused_lpar.zosmf")).toBe(false); | ||
}); | ||
}); | ||
|
||
describe("AuthHandler.lockProfile", () => { | ||
it("assigns and acquires a Mutex to the profile in the profile map", async () => { | ||
await AuthHandler.lockProfile(TEST_PROFILE_NAME); | ||
expect((AuthHandler as any).profileLocks.has(TEST_PROFILE_NAME)).toBe(true); | ||
expect((AuthHandler as any).profileLocks.get(TEST_PROFILE_NAME)).toBeInstanceOf(Mutex); | ||
AuthHandler.unlockProfile(TEST_PROFILE_NAME); | ||
}); | ||
|
||
it("handle promptForAuthentication call if error and options are given", async () => { | ||
const promptForAuthenticationMock = jest.spyOn(AuthHandler, "promptForAuthentication").mockResolvedValueOnce(true); | ||
const imperativeError = new ImperativeError({ msg: "Example auth error" }); | ||
const authOpts: AuthPromptParams = { | ||
authMethods: { | ||
promptCredentials: jest.fn(), | ||
ssoLogin: jest.fn(), | ||
}, | ||
imperativeError, | ||
}; | ||
const releaseSpy = jest.spyOn(Mutex.prototype, "release"); | ||
const result = await AuthHandler.lockProfile(TEST_PROFILE_NAME, authOpts); | ||
expect(result).toBe(true); | ||
expect(promptForAuthenticationMock).toHaveBeenCalledTimes(1); | ||
expect(promptForAuthenticationMock).toHaveBeenCalledWith(TEST_PROFILE_NAME, authOpts); | ||
expect(releaseSpy).toHaveBeenCalledTimes(1); | ||
AuthHandler.unlockProfile(TEST_PROFILE_NAME); | ||
}); | ||
|
||
it("reuses the same Mutex for the profile if it already exists", async () => { | ||
await AuthHandler.lockProfile(TEST_PROFILE_NAME); | ||
expect((AuthHandler as any).profileLocks.has(TEST_PROFILE_NAME)).toBe(true); | ||
// cache initial mutex for comparison | ||
const mutex = (AuthHandler as any).profileLocks.get(TEST_PROFILE_NAME); | ||
expect(mutex).toBeInstanceOf(Mutex); | ||
AuthHandler.unlockProfile(TEST_PROFILE_NAME); | ||
|
||
// same mutex is still present in map since lock/unlock sequence was used | ||
await AuthHandler.lockProfile(TEST_PROFILE_NAME); | ||
expect(mutex).toBe((AuthHandler as any).profileLocks.get(TEST_PROFILE_NAME)); | ||
AuthHandler.unlockProfile(TEST_PROFILE_NAME); | ||
}); | ||
}); | ||
|
||
describe("AuthHandler.promptForAuthentication", () => { | ||
it("handles a token-based authentication error - login successful, profile is string", async () => { | ||
const tokenNotValidMsg = "Token is not valid or expired."; | ||
const imperativeError = new ImperativeError({ additionalDetails: tokenNotValidMsg, msg: tokenNotValidMsg }); | ||
const ssoLogin = jest.fn().mockResolvedValue(true); | ||
const promptCredentials = jest.fn(); | ||
const showMessageMock = jest.spyOn(Gui, "showMessage").mockResolvedValueOnce("Log in to Authentication Service"); | ||
const unlockProfileSpy = jest.spyOn(AuthHandler, "unlockProfile"); | ||
await expect( | ||
AuthHandler.promptForAuthentication("lpar.zosmf", { authMethods: { promptCredentials, ssoLogin }, imperativeError }) | ||
).resolves.toBe(true); | ||
expect(promptCredentials).not.toHaveBeenCalled(); | ||
expect(ssoLogin).toHaveBeenCalledTimes(1); | ||
expect(ssoLogin).toHaveBeenCalledWith(null, "lpar.zosmf"); | ||
expect(unlockProfileSpy).toHaveBeenCalledTimes(1); | ||
expect(unlockProfileSpy).toHaveBeenCalledWith("lpar.zosmf", true); | ||
expect(showMessageMock).toHaveBeenCalledTimes(1); | ||
}); | ||
|
||
it("handles a standard authentication error - credentials provided, profile is string", async () => { | ||
const tokenNotValidMsg = "Invalid credentials"; | ||
const imperativeError = new ImperativeError({ additionalDetails: tokenNotValidMsg, msg: tokenNotValidMsg }); | ||
const ssoLogin = jest.fn().mockResolvedValue(true); | ||
const promptCredentials = jest.fn().mockResolvedValue(["us3r", "p4ssw0rd"]); | ||
const errorMessageMock = jest.spyOn(Gui, "errorMessage").mockResolvedValueOnce("Update Credentials"); | ||
const unlockProfileSpy = jest.spyOn(AuthHandler, "unlockProfile").mockClear(); | ||
await expect( | ||
AuthHandler.promptForAuthentication("lpar.zosmf", { authMethods: { promptCredentials, ssoLogin }, imperativeError }) | ||
).resolves.toBe(true); | ||
expect(unlockProfileSpy).toHaveBeenCalledTimes(1); | ||
expect(unlockProfileSpy).toHaveBeenCalledWith("lpar.zosmf", true); | ||
expect(ssoLogin).not.toHaveBeenCalled(); | ||
expect(errorMessageMock).toHaveBeenCalledTimes(1); | ||
expect(promptCredentials).toHaveBeenCalledTimes(1); | ||
expect(promptCredentials).toHaveBeenCalledWith("lpar.zosmf", true); | ||
}); | ||
}); | ||
|
||
describe("AuthHandler.unlockProfile", () => { | ||
it("releases the Mutex for the profile in the profile map", async () => { | ||
await AuthHandler.lockProfile(TEST_PROFILE_NAME); | ||
AuthHandler.unlockProfile(TEST_PROFILE_NAME); | ||
expect((AuthHandler as any).profileLocks.get(TEST_PROFILE_NAME)!.isLocked()).toBe(false); | ||
}); | ||
|
||
it("does nothing if there is no mutex in the profile map", async () => { | ||
const releaseSpy = jest.spyOn(Mutex.prototype, "release").mockClear(); | ||
AuthHandler.unlockProfile("unused_lpar.zosmf"); | ||
expect(releaseSpy).not.toHaveBeenCalled(); | ||
}); | ||
|
||
it("does nothing if the mutex in the map is not locked", async () => { | ||
await AuthHandler.lockProfile(TEST_PROFILE_NAME); | ||
AuthHandler.unlockProfile(TEST_PROFILE_NAME); | ||
|
||
const releaseSpy = jest.spyOn(Mutex.prototype, "release").mockClear(); | ||
AuthHandler.unlockProfile(TEST_PROFILE_NAME); | ||
expect(releaseSpy).not.toHaveBeenCalled(); | ||
}); | ||
|
||
it("reuses the same Mutex for the profile if it already exists", async () => { | ||
await AuthHandler.lockProfile(TEST_PROFILE_NAME); | ||
AuthHandler.unlockProfile(TEST_PROFILE_NAME); | ||
expect((AuthHandler as any).profileLocks.has(TEST_PROFILE_NAME)).toBe(true); | ||
// cache initial mutex for comparison | ||
const mutex = (AuthHandler as any).profileLocks.get(TEST_PROFILE_NAME); | ||
|
||
// same mutex is still present in map since lock/unlock sequence was used | ||
await AuthHandler.lockProfile(TEST_PROFILE_NAME); | ||
AuthHandler.unlockProfile(TEST_PROFILE_NAME); | ||
expect(mutex).toBe((AuthHandler as any).profileLocks.get(TEST_PROFILE_NAME)); | ||
}); | ||
|
||
it("refreshes resources if refreshResources parameter is true", async () => { | ||
const reloadActiveEditorMock = jest.spyOn(FileManagement, "reloadActiveEditorForProfile").mockResolvedValueOnce(undefined); | ||
const reloadWorkspaceMock = jest.spyOn(FileManagement, "reloadWorkspacesForProfile").mockResolvedValueOnce(undefined); | ||
await AuthHandler.lockProfile(TEST_PROFILE_NAME); | ||
AuthHandler.unlockProfile(TEST_PROFILE_NAME, true); | ||
expect(reloadActiveEditorMock).toHaveBeenCalledWith(TEST_PROFILE_NAME); | ||
expect(reloadWorkspaceMock).toHaveBeenCalledWith(TEST_PROFILE_NAME); | ||
}); | ||
}); |
47 changes: 47 additions & 0 deletions
47
packages/zowe-explorer-api/__tests__/__unit__/utils/DeferredPromise.unit.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
/** | ||
* This program and the accompanying materials are made available under the terms of the | ||
* Eclipse Public License v2.0 which accompanies this distribution, and is available at | ||
* https://www.eclipse.org/legal/epl-v20.html | ||
* | ||
* SPDX-License-Identifier: EPL-2.0 | ||
* | ||
* Copyright Contributors to the Zowe Project. | ||
* | ||
*/ | ||
|
||
import { DeferredPromise, DeferredPromiseStatus } from "../../../src"; | ||
|
||
describe("DeferredPromise constructor", () => { | ||
it("sets resolve and reject functions", () => { | ||
const deferred = new DeferredPromise(); | ||
expect(deferred.promise).toBeInstanceOf(Promise); | ||
expect(deferred.reject).toBeInstanceOf(Function); | ||
expect(deferred.resolve).toBeInstanceOf(Function); | ||
}); | ||
}); | ||
|
||
describe("DeferredPromise.status", () => { | ||
it("returns pending when not yet resolved", () => { | ||
const deferred = new DeferredPromise(); | ||
expect(deferred.status).toBe(DeferredPromiseStatus.Pending); | ||
}); | ||
|
||
it("returns resolved when resolved", () => { | ||
const deferred = new DeferredPromise(); | ||
deferred.resolve(null); | ||
expect(deferred.status).toBe(DeferredPromiseStatus.Resolved); | ||
}); | ||
|
||
it("returns rejected when rejected", async () => { | ||
const deferred = new DeferredPromise(); | ||
let errorCaught = false; | ||
setImmediate(() => deferred.reject()); | ||
try { | ||
await deferred.promise; | ||
} catch (err) { | ||
errorCaught = true; | ||
} | ||
expect(deferred.status).toBe(DeferredPromiseStatus.Rejected); | ||
expect(errorCaught).toBe(true); | ||
}); | ||
}); |
111 changes: 111 additions & 0 deletions
111
packages/zowe-explorer-api/__tests__/__unit__/utils/FileManagement.unit.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,111 @@ | ||
/** | ||
* This program and the accompanying materials are made available under the terms of the | ||
* Eclipse Public License v2.0 which accompanies this distribution, and is available at | ||
* https://www.eclipse.org/legal/epl-v20.html | ||
* | ||
* SPDX-License-Identifier: EPL-2.0 | ||
* | ||
* Copyright Contributors to the Zowe Project. | ||
* | ||
*/ | ||
|
||
import { FileSystemError, FileType, Uri, window, workspace } from "vscode"; | ||
import { FileManagement } from "../../../src/utils/FileManagement"; | ||
import { IFileSystemEntry, ZoweScheme } from "../../../src"; | ||
|
||
describe("permStringToOctal", () => { | ||
it("converts drwxrwxrwx to 777", () => { | ||
expect(FileManagement.permStringToOctal("drwxrwxrwx")).toBe(777); | ||
}); | ||
|
||
it("converts d--------- to 0", () => { | ||
expect(FileManagement.permStringToOctal("d---------")).toBe(0); | ||
}); | ||
|
||
it("converts drwxr-xr-x to 755", () => { | ||
expect(FileManagement.permStringToOctal("drwxr-xr-x")).toBe(755); | ||
}); | ||
|
||
it("converts -rwxrwxrwx to 777", () => { | ||
expect(FileManagement.permStringToOctal("-rwxrwxrwx")).toBe(777); | ||
}); | ||
}); | ||
|
||
describe("reloadActiveEditorForProfile", () => { | ||
it("calls workspace.fs.{readFile,stat} to reload contents of editor", async () => { | ||
const fakeFsEntry: IFileSystemEntry = { | ||
name: "exampleFile.txt", | ||
wasAccessed: true, | ||
type: FileType.Directory, | ||
metadata: { | ||
path: "/sestest/exampleFolder/exampleFile.txt", | ||
profile: { | ||
name: "sestest", | ||
message: "", | ||
type: "zosmf", | ||
failNotFound: true, | ||
}, | ||
}, | ||
ctime: Date.now() - 10, | ||
mtime: Date.now(), | ||
size: 123, | ||
}; | ||
const fileUri = Uri.from({ scheme: ZoweScheme.USS, path: "/sestest/exampleFolder/exampleFile.txt" }); | ||
const activeTextEditorMock = jest.replaceProperty(window, "activeTextEditor", { | ||
document: { | ||
fileName: "exampleFile.txt", | ||
uri: fileUri, | ||
} as any, | ||
} as any); | ||
const statMock = jest.spyOn(workspace.fs, "stat").mockResolvedValueOnce(fakeFsEntry); | ||
const readFileMock = jest.spyOn(workspace.fs, "readFile").mockImplementationOnce(async (uri): Promise<Uint8Array> => { | ||
// wasAccessed flag should be false after reassigning in reloadActiveEditorForProfile | ||
expect(fakeFsEntry.wasAccessed).toBe(false); | ||
return new Uint8Array([1, 2, 3]); | ||
}); | ||
await FileManagement.reloadActiveEditorForProfile("sestest"); | ||
expect(statMock).toHaveBeenCalledTimes(1); | ||
expect(statMock).toHaveBeenCalledWith(fileUri); | ||
expect(readFileMock).toHaveBeenCalledTimes(1); | ||
expect(readFileMock).toHaveBeenCalledWith(fileUri); | ||
activeTextEditorMock.restore(); | ||
}); | ||
}); | ||
|
||
describe("reloadWorkspacesForProfile", () => { | ||
it("calls workspace.fs.stat with fetch=true for each workspace folder", async () => { | ||
const folderUri = Uri.from({ scheme: ZoweScheme.USS, path: "/sestest/exampleFolder" }); | ||
const workspaceFoldersMock = jest.replaceProperty(workspace, "workspaceFolders", [ | ||
{ | ||
uri: folderUri, | ||
name: "exampleFolder", | ||
index: 0, | ||
}, | ||
]); | ||
const statMock = jest | ||
.spyOn(workspace.fs, "stat") | ||
.mockClear() | ||
.mockResolvedValueOnce(undefined as any); | ||
await FileManagement.reloadWorkspacesForProfile("sestest"); | ||
expect(statMock).toHaveBeenCalledTimes(1); | ||
expect(statMock).toHaveBeenCalledWith(folderUri.with({ query: "fetch=true" })); | ||
workspaceFoldersMock.restore(); | ||
}); | ||
it("calls console.error in event of an error", async () => { | ||
const folderUri = Uri.from({ scheme: ZoweScheme.USS, path: "/sestest/exampleFolder" }); | ||
const workspaceFoldersMock = jest.replaceProperty(workspace, "workspaceFolders", [ | ||
{ | ||
uri: folderUri, | ||
name: "exampleFolder", | ||
index: 0, | ||
}, | ||
]); | ||
const statMock = jest.spyOn(workspace.fs, "stat").mockClear().mockRejectedValueOnce(FileSystemError.FileNotFound(folderUri)); | ||
const consoleErrorMock = jest.spyOn(console, "error").mockImplementationOnce(() => {}); | ||
await FileManagement.reloadWorkspacesForProfile("sestest"); | ||
expect(statMock).toHaveBeenCalledTimes(1); | ||
expect(statMock).toHaveBeenCalledWith(folderUri.with({ query: "fetch=true" })); | ||
expect(consoleErrorMock).toHaveBeenCalledWith("reloadWorkspacesForProfile:", "file not found"); | ||
workspaceFoldersMock.restore(); | ||
}); | ||
}); |
32 changes: 0 additions & 32 deletions
32
packages/zowe-explorer-api/__tests__/__unit__/utils/files.unit.test.ts
This file was deleted.
Oops, something went wrong.
Oops, something went wrong.