1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

Fix <Extensions> tests by removing mockFs and making everything injectable

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton 2022-08-24 12:49:06 -04:00
parent 46db3a6b7a
commit 69bd42357f
39 changed files with 483 additions and 189 deletions

View File

@ -1,19 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
/**
* Mock the global window variable
*/
export function mockWindow() {
Object.defineProperty(window, "requestIdleCallback", {
writable: true,
value: jest.fn().mockImplementation(callback => callback()),
});
Object.defineProperty(window, "cancelIdleCallback", {
writable: true,
value: jest.fn(),
});
}

View File

@ -0,0 +1,11 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getGlobalOverride } from "../test-utils/get-global-override";
import accessPathInjectable from "./access-path.injectable";
export default getGlobalOverride(accessPathInjectable, () => async () => {
throw new Error("tried to verify path access without override");
});

View File

@ -0,0 +1,27 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import fsInjectable from "./fs.injectable";
export type AccessPath = (path: string, mode?: number) => Promise<boolean>;
const accessPathInjectable = getInjectable({
id: "access-path",
instantiate: (di): AccessPath => {
const { access } = di.inject(fsInjectable);
return async (path, mode) => {
try {
await access(path, mode);
return true;
} catch {
return false;
}
};
},
});
export default accessPathInjectable;

View File

@ -0,0 +1,11 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getGlobalOverride } from "../test-utils/get-global-override";
import copyInjectable from "./copy.injectable";
export default getGlobalOverride(copyInjectable, () => async () => {
throw new Error("tried to copy filepaths without override");
});

View File

@ -0,0 +1,16 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import type { CopyOptions } from "fs-extra";
import fsInjectable from "./fs.injectable";
export type Copy = (src: string, dest: string, options?: CopyOptions | undefined) => Promise<void>;
const copyInjectable = getInjectable({
id: "copy",
instantiate: (di): Copy => di.inject(fsInjectable).copy,
});
export default copyInjectable;

View File

@ -0,0 +1,11 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getGlobalOverride } from "../test-utils/get-global-override";
import ensureDirInjectable from "./ensure-dir.injectable";
export default getGlobalOverride(ensureDirInjectable, () => async () => {
throw new Error("tried to ensure directory without override");
});

View File

@ -5,14 +5,14 @@
import { getInjectable } from "@ogre-tools/injectable"; import { getInjectable } from "@ogre-tools/injectable";
import fsInjectable from "./fs.injectable"; import fsInjectable from "./fs.injectable";
export type EnsureDirectory = (dirPath: string) => Promise<void>;
const ensureDirInjectable = getInjectable({ const ensureDirInjectable = getInjectable({
id: "ensure-dir", id: "ensure-dir",
// TODO: Remove usages of ensureDir from business logic. // TODO: Remove usages of ensureDir from business logic.
// TODO: Read, Write, Watch etc. operations should do this internally. // TODO: Read, Write, Watch etc. operations should do this internally.
instantiate: (di) => di.inject(fsInjectable).ensureDir, instantiate: (di): EnsureDirectory => di.inject(fsInjectable).ensureDir,
causesSideEffects: true,
}); });
export default ensureDirInjectable; export default ensureDirInjectable;

View File

@ -0,0 +1,11 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getGlobalOverride } from "../test-utils/get-global-override";
import lstatInjectable from "./lstat.injectable";
export default getGlobalOverride(lstatInjectable, () => async () => {
throw new Error("tried to lstat a filepath without override");
});

View File

@ -0,0 +1,16 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import type { Stats } from "fs";
import fsInjectable from "./fs.injectable";
export type LStat = (path: string) => Promise<Stats>;
const lstatInjectable = getInjectable({
id: "lstat",
instantiate: (di): LStat => di.inject(fsInjectable).lstat,
});
export default lstatInjectable;

View File

@ -0,0 +1,11 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getGlobalOverride } from "../test-utils/get-global-override";
import readDirInjectable from "./read-dir.injectable";
export default getGlobalOverride(readDirInjectable, () => async () => {
throw new Error("tried to read a directory's content without override");
});

View File

@ -3,11 +3,35 @@
* Licensed under MIT License. See LICENSE in root directory for more information. * Licensed under MIT License. See LICENSE in root directory for more information.
*/ */
import { getInjectable } from "@ogre-tools/injectable"; import { getInjectable } from "@ogre-tools/injectable";
import type { Dirent } from "fs";
import fsInjectable from "./fs.injectable"; import fsInjectable from "./fs.injectable";
export interface ReadDirectory {
(
path: string,
options: "buffer" | { encoding: "buffer"; withFileTypes?: false | undefined }
): Promise<Buffer[]>;
(
path: string,
options?:
| { encoding: BufferEncoding | string | null; withFileTypes?: false | undefined }
| BufferEncoding
| string
| null,
): Promise<string[]>;
(
path: string,
options?: { encoding?: BufferEncoding | string | null | undefined; withFileTypes?: false | undefined },
): Promise<string[] | Buffer[]>;
(
path: string,
options: { encoding?: BufferEncoding | string | null | undefined; withFileTypes: true },
): Promise<Dirent[]>;
}
const readDirInjectable = getInjectable({ const readDirInjectable = getInjectable({
id: "read-dir", id: "read-dir",
instantiate: (di) => di.inject(fsInjectable).readdir, instantiate: (di): ReadDirectory => di.inject(fsInjectable).readdir,
}); });
export default readDirInjectable; export default readDirInjectable;

View File

@ -0,0 +1,10 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import path from "path";
import { getGlobalOverride } from "../test-utils/get-global-override";
import getAbsolutePathInjectable from "./get-absolute-path.injectable";
export default getGlobalOverride(getAbsolutePathInjectable, () => path.posix.resolve);

View File

@ -0,0 +1,10 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import path from "path";
import { getGlobalOverride } from "../test-utils/get-global-override";
import getBasenameOfPathInjectable from "./get-basename.injectable";
export default getGlobalOverride(getBasenameOfPathInjectable, () => path.posix.basename);

View File

@ -0,0 +1,16 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import path from "path";
export type GetBasenameOfPath = (path: string) => string;
const getBasenameOfPathInjectable = getInjectable({
id: "get-basename-of-path",
instantiate: (): GetBasenameOfPath => path.basename,
causesSideEffects: true,
});
export default getBasenameOfPathInjectable;

View File

@ -0,0 +1,10 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import path from "path";
import { getGlobalOverride } from "../test-utils/get-global-override";
import getDirnameOfPathInjectable from "./get-dirname.injectable";
export default getGlobalOverride(getDirnameOfPathInjectable, () => path.posix.dirname);

View File

@ -0,0 +1,16 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import path from "path";
export type GetDirnameOfPath = (path: string) => string;
const getDirnameOfPathInjectable = getInjectable({
id: "get-dirname-of-path",
instantiate: (): GetDirnameOfPath => path.dirname,
causesSideEffects: true,
});
export default getDirnameOfPathInjectable;

View File

@ -0,0 +1,10 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import path from "path";
import { getGlobalOverride } from "../test-utils/get-global-override";
import getRelativePathInjectable from "./get-relative-path.injectable";
export default getGlobalOverride(getRelativePathInjectable, () => path.posix.relative);

View File

@ -0,0 +1,16 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import path from "path";
export type GetRelativePath = (from: string, to: string) => string;
const getRelativePathInjectable = getInjectable({
id: "get-relative-path",
instantiate: (): GetRelativePath => path.relative,
causesSideEffects: true,
});
export default getRelativePathInjectable;

View File

@ -0,0 +1,10 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import path from "path";
import { getGlobalOverride } from "../test-utils/get-global-override";
import joinPathsInjectable from "./join-paths.injectable";
export default getGlobalOverride(joinPathsInjectable, () => path.posix.join);

View File

@ -0,0 +1,10 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import path from "path";
import { getGlobalOverride } from "../test-utils/get-global-override";
import fileSystemSeparatorInjectable from "./separator.injectable";
export default getGlobalOverride(fileSystemSeparatorInjectable, () => path.posix.sep);

View File

@ -0,0 +1,14 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import path from "path";
const fileSystemSeparatorInjectable = getInjectable({
id: "file-system-separator",
instantiate: () => path.sep,
causesSideEffects: true,
});
export default fileSystemSeparatorInjectable;

View File

@ -1,17 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { GetAbsolutePath } from "../path/get-absolute-path.injectable";
export const getAbsolutePathFake: GetAbsolutePath = (...args) => {
const maybeAbsolutePath = args.join("/");
if (isAbsolutePath(maybeAbsolutePath)) {
return maybeAbsolutePath;
}
return `/some-absolute-root-directory/${maybeAbsolutePath}`;
};
const isAbsolutePath = (path: string) => path.startsWith("/");

View File

@ -1,7 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { JoinPaths } from "../path/join-paths.injectable";
export const joinPathsFake: JoinPaths = (...args) => args.join("/");

View File

@ -16,6 +16,18 @@ import readJsonFileInjectable from "../../common/fs/read-json-file.injectable";
import loggerInjectable from "../../common/logger.injectable"; import loggerInjectable from "../../common/logger.injectable";
import pathExistsInjectable from "../../common/fs/path-exists.injectable"; import pathExistsInjectable from "../../common/fs/path-exists.injectable";
import watchInjectable from "../../common/fs/watch/watch.injectable"; import watchInjectable from "../../common/fs/watch/watch.injectable";
import accessPathInjectable from "../../common/fs/access-path.injectable";
import copyInjectable from "../../common/fs/copy.injectable";
import deleteFileInjectable from "../../common/fs/delete-file.injectable";
import ensureDirInjectable from "../../common/fs/ensure-dir.injectable";
import isProductionInjectable from "../../common/vars/is-production.injectable";
import lstatInjectable from "../../common/fs/lstat.injectable";
import readDirInjectable from "../../common/fs/read-dir.injectable";
import fileSystemSeparatorInjectable from "../../common/path/separator.injectable";
import getBasenameOfPathInjectable from "../../common/path/get-basename.injectable";
import getDirnameOfPathInjectable from "../../common/path/get-dirname.injectable";
import getRelativePathInjectable from "../../common/path/get-relative-path.injectable";
import joinPathsInjectable from "../../common/path/join-paths.injectable";
const extensionDiscoveryInjectable = getInjectable({ const extensionDiscoveryInjectable = getInjectable({
id: "extension-discovery", id: "extension-discovery",
@ -33,6 +45,18 @@ const extensionDiscoveryInjectable = getInjectable({
pathExists: di.inject(pathExistsInjectable), pathExists: di.inject(pathExistsInjectable),
watch: di.inject(watchInjectable), watch: di.inject(watchInjectable),
logger: di.inject(loggerInjectable), logger: di.inject(loggerInjectable),
accessPath: di.inject(accessPathInjectable),
copy: di.inject(copyInjectable),
deleteFile: di.inject(deleteFileInjectable),
ensureDirectory: di.inject(ensureDirInjectable),
isProduction: di.inject(isProductionInjectable),
lstat: di.inject(lstatInjectable),
readDirectory: di.inject(readDirInjectable),
fileSystemSeparator: di.inject(fileSystemSeparatorInjectable),
getBasenameOfPath: di.inject(getBasenameOfPathInjectable),
getDirnameOfPath: di.inject(getDirnameOfPathInjectable),
getRelativePath: di.inject(getRelativePathInjectable),
joinPaths: di.inject(joinPathsInjectable),
}), }),
}); });

View File

@ -5,16 +5,13 @@
import { ipcRenderer } from "electron"; import { ipcRenderer } from "electron";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import fse from "fs-extra";
import { makeObservable, observable, reaction, when } from "mobx"; import { makeObservable, observable, reaction, when } from "mobx";
import os from "os"; import os from "os";
import path from "path";
import { broadcastMessage, ipcMainHandle, ipcRendererOn } from "../../common/ipc"; import { broadcastMessage, ipcMainHandle, ipcRendererOn } from "../../common/ipc";
import { isErrnoException, toJS } from "../../common/utils"; import { isErrnoException, toJS } from "../../common/utils";
import type { ExtensionsStore } from "../extensions-store/extensions-store"; import type { ExtensionsStore } from "../extensions-store/extensions-store";
import type { ExtensionLoader } from "../extension-loader"; import type { ExtensionLoader } from "../extension-loader";
import type { LensExtensionId, LensExtensionManifest } from "../lens-extension"; import type { LensExtensionId, LensExtensionManifest } from "../lens-extension";
import { isProduction } from "../../common/vars";
import type { ExtensionInstallationStateStore } from "../extension-installation-state-store/extension-installation-state-store"; import type { ExtensionInstallationStateStore } from "../extension-installation-state-store/extension-installation-state-store";
import type { PackageJson } from "type-fest"; import type { PackageJson } from "type-fest";
import { extensionDiscoveryStateChannel } from "../../common/ipc/extension-handling"; import { extensionDiscoveryStateChannel } from "../../common/ipc/extension-handling";
@ -23,20 +20,44 @@ import type { ReadJson } from "../../common/fs/read-json-file.injectable";
import type { Logger } from "../../common/logger"; import type { Logger } from "../../common/logger";
import type { PathExists } from "../../common/fs/path-exists.injectable"; import type { PathExists } from "../../common/fs/path-exists.injectable";
import type { Watch } from "../../common/fs/watch/watch.injectable"; import type { Watch } from "../../common/fs/watch/watch.injectable";
import type { Stats } from "fs";
import { constants } from "fs";
import type { DeleteFile } from "../../common/fs/delete-file.injectable";
import type { LStat } from "../../common/fs/lstat.injectable";
import type { ReadDirectory } from "../../common/fs/read-dir.injectable";
import type { EnsureDirectory } from "../../common/fs/ensure-dir.injectable";
import type { AccessPath } from "../../common/fs/access-path.injectable";
import type { Copy } from "../../common/fs/copy.injectable";
import type { JoinPaths } from "../../common/path/join-paths.injectable";
import type { GetBasenameOfPath } from "../../common/path/get-basename.injectable";
import type { GetDirnameOfPath } from "../../common/path/get-dirname.injectable";
import type { GetRelativePath } from "../../common/path/get-relative-path.injectable";
interface Dependencies { interface Dependencies {
extensionLoader: ExtensionLoader; readonly extensionLoader: ExtensionLoader;
extensionsStore: ExtensionsStore; readonly extensionsStore: ExtensionsStore;
extensionInstallationStateStore: ExtensionInstallationStateStore; readonly extensionInstallationStateStore: ExtensionInstallationStateStore;
readonly extensionPackageRootDirectory: string;
readonly staticFilesDirectory: string;
readonly logger: Logger;
readonly isProduction: boolean;
readonly fileSystemSeparator: string;
isCompatibleExtension: (manifest: LensExtensionManifest) => boolean; isCompatibleExtension: (manifest: LensExtensionManifest) => boolean;
installExtension: (name: string) => Promise<void>; installExtension: (name: string) => Promise<void>;
installExtensions: (packageJsonPath: string, packagesJson: PackageJson) => Promise<void>; installExtensions: (packageJsonPath: string, packagesJson: PackageJson) => Promise<void>;
extensionPackageRootDirectory: string;
staticFilesDirectory: string;
readJsonFile: ReadJson; readJsonFile: ReadJson;
pathExists: PathExists; pathExists: PathExists;
deleteFile: DeleteFile;
lstat: LStat;
watch: Watch; watch: Watch;
logger: Logger; readDirectory: ReadDirectory;
ensureDirectory: EnsureDirectory;
accessPath: AccessPath;
copy: Copy;
joinPaths: JoinPaths;
getBasenameOfPath: GetBasenameOfPath;
getDirnameOfPath: GetDirnameOfPath;
getRelativePath: GetRelativePath;
} }
export interface InstalledExtension { export interface InstalledExtension {
@ -67,7 +88,7 @@ interface ExtensionDiscoveryChannelMessage {
* Returns true if the lstat is for a directory-like file (e.g. isDirectory or symbolic link) * Returns true if the lstat is for a directory-like file (e.g. isDirectory or symbolic link)
* @param lstat the stats to compare * @param lstat the stats to compare
*/ */
const isDirectoryLike = (lstat: fse.Stats) => lstat.isDirectory() || lstat.isSymbolicLink(); const isDirectoryLike = (lstat: Stats) => lstat.isDirectory() || lstat.isSymbolicLink();
interface LoadFromFolderOptions { interface LoadFromFolderOptions {
isBundled?: boolean; isBundled?: boolean;
@ -103,23 +124,23 @@ export class ExtensionDiscovery {
} }
get localFolderPath(): string { get localFolderPath(): string {
return path.join(os.homedir(), ".k8slens", "extensions"); return this.dependencies.joinPaths(os.homedir(), ".k8slens", "extensions");
} }
get packageJsonPath(): string { get packageJsonPath(): string {
return path.join(this.dependencies.extensionPackageRootDirectory, manifestFilename); return this.dependencies.joinPaths(this.dependencies.extensionPackageRootDirectory, manifestFilename);
} }
get inTreeTargetPath(): string { get inTreeTargetPath(): string {
return path.join(this.dependencies.extensionPackageRootDirectory, "extensions"); return this.dependencies.joinPaths(this.dependencies.extensionPackageRootDirectory, "extensions");
} }
get inTreeFolderPath(): string { get inTreeFolderPath(): string {
return path.resolve(this.dependencies.staticFilesDirectory, "../extensions"); return this.dependencies.joinPaths(this.dependencies.staticFilesDirectory, "../extensions");
} }
get nodeModulesPath(): string { get nodeModulesPath(): string {
return path.join(this.dependencies.extensionPackageRootDirectory, "node_modules"); return this.dependencies.joinPaths(this.dependencies.extensionPackageRootDirectory, "node_modules");
} }
/** /**
@ -184,24 +205,24 @@ export class ExtensionDiscovery {
handleWatchFileAdd = async (manifestPath: string): Promise<void> => { handleWatchFileAdd = async (manifestPath: string): Promise<void> => {
// e.g. "foo/package.json" // e.g. "foo/package.json"
const relativePath = path.relative(this.localFolderPath, manifestPath); const relativePath = this.dependencies.getRelativePath(this.localFolderPath, manifestPath);
// Converts "foo/package.json" to ["foo", "package.json"], where length of 2 implies // Converts "foo/package.json" to ["foo", "package.json"], where length of 2 implies
// that the added file is in a folder under local folder path. // that the added file is in a folder under local folder path.
// This safeguards against a file watch being triggered under a sub-directory which is not an extension. // This safeguards against a file watch being triggered under a sub-directory which is not an extension.
const isUnderLocalFolderPath = relativePath.split(path.sep).length === 2; const isUnderLocalFolderPath = relativePath.split(this.dependencies.fileSystemSeparator).length === 2;
if (path.basename(manifestPath) === manifestFilename && isUnderLocalFolderPath) { if (this.dependencies.getBasenameOfPath(manifestPath) === manifestFilename && isUnderLocalFolderPath) {
try { try {
this.dependencies.extensionInstallationStateStore.setInstallingFromMain(manifestPath); this.dependencies.extensionInstallationStateStore.setInstallingFromMain(manifestPath);
const absPath = path.dirname(manifestPath); const absPath = this.dependencies.getDirnameOfPath(manifestPath);
// this.loadExtensionFromPath updates this.packagesJson // this.loadExtensionFromPath updates this.packagesJson
const extension = await this.loadExtensionFromFolder(absPath); const extension = await this.loadExtensionFromFolder(absPath);
if (extension) { if (extension) {
// Remove a broken symlink left by a previous installation if it exists. // Remove a broken symlink left by a previous installation if it exists.
await fse.remove(extension.manifestPath); await this.dependencies.deleteFile(extension.manifestPath);
// Install dependencies for the new extension // Install dependencies for the new extension
await this.dependencies.installExtension(extension.absolutePath); await this.dependencies.installExtension(extension.absolutePath);
@ -226,8 +247,8 @@ export class ExtensionDiscovery {
handleWatchUnlinkEvent = async (filePath: string): Promise<void> => { handleWatchUnlinkEvent = async (filePath: string): Promise<void> => {
// Check that the removed path is directly under this.localFolderPath // Check that the removed path is directly under this.localFolderPath
// Note that the watcher can create unlink events for subdirectories of the extension // Note that the watcher can create unlink events for subdirectories of the extension
const extensionFolderName = path.basename(filePath); const extensionFolderName = this.dependencies.getBasenameOfPath(filePath);
const expectedPath = path.relative(this.localFolderPath, filePath); const expectedPath = this.dependencies.getRelativePath(this.localFolderPath, filePath);
if (expectedPath !== extensionFolderName) { if (expectedPath !== extensionFolderName) {
return; return;
@ -264,7 +285,7 @@ export class ExtensionDiscovery {
* @param name e.g. "@mirantis/lens-extension-cc" * @param name e.g. "@mirantis/lens-extension-cc"
*/ */
removeSymlinkByPackageName(name: string): Promise<void> { removeSymlinkByPackageName(name: string): Promise<void> {
return fse.remove(this.getInstalledPath(name)); return this.dependencies.deleteFile(this.getInstalledPath(name));
} }
/** /**
@ -286,7 +307,7 @@ export class ExtensionDiscovery {
await this.removeSymlinkByPackageName(manifest.name); await this.removeSymlinkByPackageName(manifest.name);
// fs.remove does nothing if the path doesn't exist anymore // fs.remove does nothing if the path doesn't exist anymore
await fse.remove(absolutePath); await this.dependencies.deleteFile(absolutePath);
} }
async load(): Promise<Map<LensExtensionId, InstalledExtension>> { async load(): Promise<Map<LensExtensionId, InstalledExtension>> {
@ -301,34 +322,29 @@ export class ExtensionDiscovery {
`${logModule} loading extensions from ${this.dependencies.extensionPackageRootDirectory}`, `${logModule} loading extensions from ${this.dependencies.extensionPackageRootDirectory}`,
); );
// fs.remove won't throw if path is missing await this.dependencies.deleteFile(this.dependencies.joinPaths(this.dependencies.extensionPackageRootDirectory, "package-lock.json"));
await fse.remove(path.join(this.dependencies.extensionPackageRootDirectory, "package-lock.json"));
try { const canWriteToInTreeFolder = await this.dependencies.accessPath(this.inTreeFolderPath, constants.W_OK);
// Verify write access to static/extensions, which is needed for symlinking
await fse.access(this.inTreeFolderPath, fse.constants.W_OK);
if (canWriteToInTreeFolder) {
// Set bundled folder path to static/extensions // Set bundled folder path to static/extensions
this.bundledFolderPath = this.inTreeFolderPath; this.bundledFolderPath = this.inTreeFolderPath;
} catch { } else {
// If there is error accessing static/extensions, we need to copy in-tree extensions so that we can symlink them properly on "npm install".
// The error can happen if there is read-only rights to static/extensions, which would fail symlinking.
// Remove e.g. /Users/<username>/Library/Application Support/LensDev/extensions // Remove e.g. /Users/<username>/Library/Application Support/LensDev/extensions
await fse.remove(this.inTreeTargetPath); await this.dependencies.deleteFile(this.inTreeTargetPath);
// Create folder e.g. /Users/<username>/Library/Application Support/LensDev/extensions // Create folder e.g. /Users/<username>/Library/Application Support/LensDev/extensions
await fse.ensureDir(this.inTreeTargetPath); await this.dependencies.ensureDirectory(this.inTreeTargetPath);
// Copy static/extensions to e.g. /Users/<username>/Library/Application Support/LensDev/extensions // Copy static/extensions to e.g. /Users/<username>/Library/Application Support/LensDev/extensions
await fse.copy(this.inTreeFolderPath, this.inTreeTargetPath); await this.dependencies.copy(this.inTreeFolderPath, this.inTreeTargetPath);
// Set bundled folder path to e.g. /Users/<username>/Library/Application Support/LensDev/extensions // Set bundled folder path to e.g. /Users/<username>/Library/Application Support/LensDev/extensions
this.bundledFolderPath = this.inTreeTargetPath; this.bundledFolderPath = this.inTreeTargetPath;
} }
await fse.ensureDir(this.nodeModulesPath); await this.dependencies.ensureDirectory(this.nodeModulesPath);
await fse.ensureDir(this.localFolderPath); await this.dependencies.ensureDirectory(this.localFolderPath);
const extensions = await this.ensureExtensions(); const extensions = await this.ensureExtensions();
@ -342,7 +358,7 @@ export class ExtensionDiscovery {
* e.g. "/Users/<username>/Library/Application Support/Lens/node_modules/@publisher/extension" * e.g. "/Users/<username>/Library/Application Support/Lens/node_modules/@publisher/extension"
*/ */
protected getInstalledPath(name: string): string { protected getInstalledPath(name: string): string {
return path.join(this.nodeModulesPath, name); return this.dependencies.joinPaths(this.nodeModulesPath, name);
} }
/** /**
@ -350,7 +366,7 @@ export class ExtensionDiscovery {
* e.g. "/Users/<username>/Library/Application Support/Lens/node_modules/@publisher/extension/package.json" * e.g. "/Users/<username>/Library/Application Support/Lens/node_modules/@publisher/extension/package.json"
*/ */
protected getInstalledManifestPath(name: string): string { protected getInstalledManifestPath(name: string): string {
return path.join(this.getInstalledPath(name), manifestFilename); return this.dependencies.joinPaths(this.getInstalledPath(name), manifestFilename);
} }
/** /**
@ -362,10 +378,11 @@ export class ExtensionDiscovery {
const manifest = await this.dependencies.readJsonFile(manifestPath) as unknown as LensExtensionManifest; const manifest = await this.dependencies.readJsonFile(manifestPath) as unknown as LensExtensionManifest;
const id = this.getInstalledManifestPath(manifest.name); const id = this.getInstalledManifestPath(manifest.name);
const isEnabled = this.dependencies.extensionsStore.isEnabled({ id, isBundled }); const isEnabled = this.dependencies.extensionsStore.isEnabled({ id, isBundled });
const extensionDir = path.dirname(manifestPath); const extensionDir = this.dependencies.getDirnameOfPath(manifestPath);
const packedName = manifest.name.replaceAll("@", "").replaceAll("/", "-"); const npmPackage = this.dependencies.joinPaths(extensionDir, `${manifest.name}-${manifest.version}.tgz`);
const npmPackage = path.join(extensionDir, `${packedName}-${manifest.version}.tgz`); const absolutePath = this.dependencies.isProduction && await this.dependencies.pathExists(npmPackage)
const absolutePath = (isProduction && await this.dependencies.pathExists(npmPackage)) ? npmPackage : extensionDir; ? npmPackage
: extensionDir;
const isCompatible = isBundled || this.dependencies.isCompatibleExtension(manifest); const isCompatible = isBundled || this.dependencies.isCompatibleExtension(manifest);
return { return {
@ -416,10 +433,10 @@ export class ExtensionDiscovery {
async loadBundledExtensions(): Promise<InstalledExtension[]> { async loadBundledExtensions(): Promise<InstalledExtension[]> {
const extensions: InstalledExtension[] = []; const extensions: InstalledExtension[] = [];
const folderPath = this.bundledFolderPath; const folderPath = this.bundledFolderPath;
const paths = await fse.readdir(folderPath); const paths = await this.dependencies.readDirectory(folderPath);
for (const fileName of paths) { for (const fileName of paths) {
const absPath = path.resolve(folderPath, fileName); const absPath = this.dependencies.joinPaths(folderPath, fileName);
const extension = await this.loadExtensionFromFolder(absPath, { isBundled: true }); const extension = await this.loadExtensionFromFolder(absPath, { isBundled: true });
if (extension) { if (extension) {
@ -433,7 +450,7 @@ export class ExtensionDiscovery {
async loadFromFolder(folderPath: string, bundledExtensions: string[]): Promise<InstalledExtension[]> { async loadFromFolder(folderPath: string, bundledExtensions: string[]): Promise<InstalledExtension[]> {
const extensions: InstalledExtension[] = []; const extensions: InstalledExtension[] = [];
const paths = await fse.readdir(folderPath); const paths = await this.dependencies.readDirectory(folderPath);
for (const fileName of paths) { for (const fileName of paths) {
// do not allow to override bundled extensions // do not allow to override bundled extensions
@ -441,17 +458,21 @@ export class ExtensionDiscovery {
continue; continue;
} }
const absPath = path.resolve(folderPath, fileName); const absPath = this.dependencies.joinPaths(folderPath, fileName);
if (!fse.existsSync(absPath)) { try {
continue; const lstat = await this.dependencies.lstat(absPath);
}
const lstat = await fse.lstat(absPath); // skip non-directories
if (!isDirectoryLike(lstat)) {
continue;
}
} catch (error) {
if (isErrnoException(error) && error.code === "ENOENT") {
continue;
}
// skip non-directories throw error;
if (!isDirectoryLike(lstat)) {
continue;
} }
const extension = await this.loadExtensionFromFolder(absPath); const extension = await this.loadExtensionFromFolder(absPath);
@ -471,7 +492,7 @@ export class ExtensionDiscovery {
* @param folderPath Folder path to extension * @param folderPath Folder path to extension
*/ */
async loadExtensionFromFolder(folderPath: string, { isBundled = false }: LoadFromFolderOptions = {}): Promise<InstalledExtension | null> { async loadExtensionFromFolder(folderPath: string, { isBundled = false }: LoadFromFolderOptions = {}): Promise<InstalledExtension | null> {
const manifestPath = path.resolve(folderPath, manifestFilename); const manifestPath = this.dependencies.joinPaths(folderPath, manifestFilename);
return this.getByManifest(manifestPath, { isBundled }); return this.getByManifest(manifestPath, { isBundled });
} }

View File

@ -9,6 +9,7 @@ import { createExtensionInstanceInjectionToken } from "./create-extension-instan
import extensionInstancesInjectable from "./extension-instances.injectable"; import extensionInstancesInjectable from "./extension-instances.injectable";
import type { LensExtension } from "../lens-extension"; import type { LensExtension } from "../lens-extension";
import extensionInjectable from "./extension/extension.injectable"; import extensionInjectable from "./extension/extension.injectable";
import loggerInjectable from "../../common/logger.injectable";
const extensionLoaderInjectable = getInjectable({ const extensionLoaderInjectable = getInjectable({
id: "extension-loader", id: "extension-loader",
@ -18,6 +19,7 @@ const extensionLoaderInjectable = getInjectable({
createExtensionInstance: di.inject(createExtensionInstanceInjectionToken), createExtensionInstance: di.inject(createExtensionInstanceInjectionToken),
extensionInstances: di.inject(extensionInstancesInjectable), extensionInstances: di.inject(extensionInstancesInjectable),
getExtension: (instance: LensExtension) => di.inject(extensionInjectable, instance), getExtension: (instance: LensExtension) => di.inject(extensionInjectable, instance),
logger: di.inject(loggerInjectable),
}), }),
}); });

View File

@ -11,7 +11,6 @@ import path from "path";
import { broadcastMessage, ipcMainOn, ipcRendererOn, ipcMainHandle } from "../../common/ipc"; import { broadcastMessage, ipcMainOn, ipcRendererOn, ipcMainHandle } from "../../common/ipc";
import type { Disposer } from "../../common/utils"; import type { Disposer } from "../../common/utils";
import { isDefined, toJS } from "../../common/utils"; import { isDefined, toJS } from "../../common/utils";
import logger from "../../main/logger";
import type { InstalledExtension } from "../extension-discovery/extension-discovery"; import type { InstalledExtension } from "../extension-discovery/extension-discovery";
import type { LensExtension, LensExtensionConstructor, LensExtensionId } from "../lens-extension"; import type { LensExtension, LensExtensionConstructor, LensExtensionId } from "../lens-extension";
import type { LensRendererExtension } from "../lens-renderer-extension"; import type { LensRendererExtension } from "../lens-renderer-extension";
@ -23,13 +22,15 @@ import assert from "assert";
import { EventEmitter } from "../../common/event-emitter"; import { EventEmitter } from "../../common/event-emitter";
import type { CreateExtensionInstance } from "./create-extension-instance.token"; import type { CreateExtensionInstance } from "./create-extension-instance.token";
import type { Extension } from "./extension/extension.injectable"; import type { Extension } from "./extension/extension.injectable";
import type { Logger } from "../../common/logger";
const logModule = "[EXTENSIONS-LOADER]"; const logModule = "[EXTENSIONS-LOADER]";
interface Dependencies { interface Dependencies {
readonly extensionInstances: ObservableMap<LensExtensionId, LensExtension>;
readonly logger: Logger;
updateExtensionsState: (extensionsState: Record<LensExtensionId, LensExtensionState>) => void; updateExtensionsState: (extensionsState: Record<LensExtensionId, LensExtensionState>) => void;
createExtensionInstance: CreateExtensionInstance; createExtensionInstance: CreateExtensionInstance;
readonly extensionInstances: ObservableMap<LensExtensionId, LensExtension>;
getExtension: (instance: LensExtension) => Extension; getExtension: (instance: LensExtension) => Extension;
} }
@ -159,7 +160,7 @@ export class ExtensionLoader {
@action @action
removeInstance(lensExtensionId: LensExtensionId) { removeInstance(lensExtensionId: LensExtensionId) {
logger.info(`${logModule} deleting extension instance ${lensExtensionId}`); this.dependencies.logger.info(`${logModule} deleting extension instance ${lensExtensionId}`);
const instance = this.dependencies.extensionInstances.get(lensExtensionId); const instance = this.dependencies.extensionInstances.get(lensExtensionId);
if (!instance) { if (!instance) {
@ -177,7 +178,7 @@ export class ExtensionLoader {
this.dependencies.extensionInstances.delete(lensExtensionId); this.dependencies.extensionInstances.delete(lensExtensionId);
this.nonInstancesByName.delete(instance.name); this.nonInstancesByName.delete(instance.name);
} catch (error) { } catch (error) {
logger.error(`${logModule}: deactivation extension error`, { lensExtensionId, error }); this.dependencies.logger.error(`${logModule}: deactivation extension error`, { lensExtensionId, error });
} }
} }
@ -252,7 +253,7 @@ export class ExtensionLoader {
} }
loadOnClusterManagerRenderer = () => { loadOnClusterManagerRenderer = () => {
logger.debug(`${logModule}: load on main renderer (cluster manager)`); this.dependencies.logger.debug(`${logModule}: load on main renderer (cluster manager)`);
return this.autoInitExtensions(async (ext) => { return this.autoInitExtensions(async (ext) => {
const extension = ext as LensRendererExtension; const extension = ext as LensRendererExtension;
@ -274,7 +275,7 @@ export class ExtensionLoader {
}; };
loadOnClusterRenderer = () => { loadOnClusterRenderer = () => {
logger.debug(`${logModule}: load on cluster renderer (dashboard)`); this.dependencies.logger.debug(`${logModule}: load on cluster renderer (dashboard)`);
this.autoInitExtensions(async () => []); this.autoInitExtensions(async () => []);
}; };
@ -313,7 +314,7 @@ export class ExtensionLoader {
activated: instance.activate(), activated: instance.activate(),
}; };
} catch (err) { } catch (err) {
logger.error(`${logModule}: error loading extension`, { ext: extension, err }); this.dependencies.logger.error(`${logModule}: error loading extension`, { ext: extension, err });
} }
} else if (!extension.isEnabled && alreadyInit) { } else if (!extension.isEnabled && alreadyInit) {
this.removeInstance(extId); this.removeInstance(extId);
@ -330,7 +331,7 @@ export class ExtensionLoader {
extensions.map(extension => extensions.map(extension =>
// If extension activation fails, log error // If extension activation fails, log error
extension.activated.catch((error) => { extension.activated.catch((error) => {
logger.error(`${logModule}: activation extension error`, { ext: extension.installedExtension, error }); this.dependencies.logger.error(`${logModule}: activation extension error`, { ext: extension.installedExtension, error });
}), }),
), ),
); );
@ -344,7 +345,7 @@ export class ExtensionLoader {
// Return ExtensionLoading[] // Return ExtensionLoading[]
return extensions.map(extension => { return extensions.map(extension => {
const loaded = extension.instance.enable(register).catch((err) => { const loaded = extension.instance.enable(register).catch((err) => {
logger.error(`${logModule}: failed to enable`, { ext: extension, err }); this.dependencies.logger.error(`${logModule}: failed to enable`, { ext: extension, err });
}); });
return { return {
@ -377,7 +378,7 @@ export class ExtensionLoader {
} catch (error) { } catch (error) {
const message = (error instanceof Error ? error.stack : undefined) || error; const message = (error instanceof Error ? error.stack : undefined) || error;
logger.error(`${logModule}: can't load ${entryPointName} for "${extension.manifest.name}": ${message}`, { extension }); this.dependencies.logger.error(`${logModule}: can't load ${entryPointName} for "${extension.manifest.name}": ${message}`, { extension });
} }
return null; return null;

View File

@ -6,8 +6,6 @@ import electronAppInjectable from "../../electron-app/electron-app.injectable";
import getElectronAppPathInjectable from "./get-electron-app-path.injectable"; import getElectronAppPathInjectable from "./get-electron-app-path.injectable";
import { getDiForUnitTesting } from "../../getDiForUnitTesting"; import { getDiForUnitTesting } from "../../getDiForUnitTesting";
import type { App } from "electron"; import type { App } from "electron";
import joinPathsInjectable from "../../../common/path/join-paths.injectable";
import { joinPathsFake } from "../../../common/test-utils/join-paths-fake";
describe("get-electron-app-path", () => { describe("get-electron-app-path", () => {
let getElectronAppPath: (name: string) => string; let getElectronAppPath: (name: string) => string;
@ -31,7 +29,6 @@ describe("get-electron-app-path", () => {
} as App; } as App;
di.override(electronAppInjectable, () => appStub); di.override(electronAppInjectable, () => appStub);
di.override(joinPathsInjectable, () => joinPathsFake);
getElectronAppPath = di.inject(getElectronAppPathInjectable) as (name: string) => string; getElectronAppPath = di.inject(getElectronAppPathInjectable) as (name: string) => string;
}); });

View File

@ -18,10 +18,6 @@ import fileSystemProvisionerStoreInjectable from "../extensions/extension-loader
import type { FileSystemProvisionerStore } from "../extensions/extension-loader/file-system-provisioner-store/file-system-provisioner-store"; import type { FileSystemProvisionerStore } from "../extensions/extension-loader/file-system-provisioner-store/file-system-provisioner-store";
import userStoreInjectable from "../common/user-store/user-store.injectable"; import userStoreInjectable from "../common/user-store/user-store.injectable";
import type { UserStore } from "../common/user-store"; import type { UserStore } from "../common/user-store";
import getAbsolutePathInjectable from "../common/path/get-absolute-path.injectable";
import { getAbsolutePathFake } from "../common/test-utils/get-absolute-path-fake";
import joinPathsInjectable from "../common/path/join-paths.injectable";
import { joinPathsFake } from "../common/test-utils/join-paths-fake";
import hotbarStoreInjectable from "../common/hotbars/store.injectable"; import hotbarStoreInjectable from "../common/hotbars/store.injectable";
import appEventBusInjectable from "../common/app-event-bus/app-event-bus.injectable"; import appEventBusInjectable from "../common/app-event-bus/app-event-bus.injectable";
import { EventEmitter } from "../common/event-emitter"; import { EventEmitter } from "../common/event-emitter";
@ -228,8 +224,6 @@ const overrideRunnablesHavingSideEffects = (di: DiContainer) => {
const overrideOperatingSystem = (di: DiContainer) => { const overrideOperatingSystem = (di: DiContainer) => {
di.override(platformInjectable, () => "darwin"); di.override(platformInjectable, () => "darwin");
di.override(getAbsolutePathInjectable, () => getAbsolutePathFake);
di.override(joinPathsInjectable, () => joinPathsFake);
di.override(normalizedPlatformArchitectureInjectable, () => "arm64"); di.override(normalizedPlatformArchitectureInjectable, () => "arm64");
}; };

View File

@ -5,14 +5,11 @@
import "@testing-library/jest-dom/extend-expect"; import "@testing-library/jest-dom/extend-expect";
import { fireEvent, screen, waitFor } from "@testing-library/react"; import { fireEvent, screen, waitFor } from "@testing-library/react";
import fse from "fs-extra";
import React from "react"; import React from "react";
import type { ExtensionDiscovery } from "../../../../extensions/extension-discovery/extension-discovery"; import type { ExtensionDiscovery } from "../../../../extensions/extension-discovery/extension-discovery";
import type { ExtensionLoader } from "../../../../extensions/extension-loader"; import type { ExtensionLoader } from "../../../../extensions/extension-loader";
import { ConfirmDialog } from "../../confirm-dialog"; import { ConfirmDialog } from "../../confirm-dialog";
import { Extensions } from "../extensions"; import { Extensions } from "../extensions";
import mockFs from "mock-fs";
import { mockWindow } from "../../../../../__mocks__/windowMock";
import { getDiForUnitTesting } from "../../../getDiForUnitTesting"; import { getDiForUnitTesting } from "../../../getDiForUnitTesting";
import extensionLoaderInjectable from "../../../../extensions/extension-loader/extension-loader.injectable"; import extensionLoaderInjectable from "../../../../extensions/extension-loader/extension-loader.injectable";
import type { DiRender } from "../../test-utils/renderFor"; import type { DiRender } from "../../test-utils/renderFor";
@ -20,18 +17,15 @@ import { renderFor } from "../../test-utils/renderFor";
import extensionDiscoveryInjectable from "../../../../extensions/extension-discovery/extension-discovery.injectable"; import extensionDiscoveryInjectable from "../../../../extensions/extension-discovery/extension-discovery.injectable";
import directoryForUserDataInjectable from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; import directoryForUserDataInjectable from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
import directoryForDownloadsInjectable from "../../../../common/app-paths/directory-for-downloads/directory-for-downloads.injectable"; import directoryForDownloadsInjectable from "../../../../common/app-paths/directory-for-downloads/directory-for-downloads.injectable";
import getConfigurationFileModelInjectable from "../../../../common/get-configuration-file-model/get-configuration-file-model.injectable";
import assert from "assert"; import assert from "assert";
import type { InstallFromInput } from "../install-from-input/install-from-input"; import type { InstallFromInput } from "../install-from-input/install-from-input";
import installFromInputInjectable from "../install-from-input/install-from-input.injectable"; import installFromInputInjectable from "../install-from-input/install-from-input.injectable";
import type { ExtensionInstallationStateStore } from "../../../../extensions/extension-installation-state-store/extension-installation-state-store"; import type { ExtensionInstallationStateStore } from "../../../../extensions/extension-installation-state-store/extension-installation-state-store";
import extensionInstallationStateStoreInjectable from "../../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable"; import extensionInstallationStateStoreInjectable from "../../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable";
import { observable, when } from "mobx"; import { observable, when } from "mobx";
import type { DeleteFile } from "../../../../common/fs/delete-file.injectable";
import deleteFileInjectable from "../../../../common/fs/delete-file.injectable";
mockWindow();
jest.setTimeout(30000);
jest.mock("fs-extra");
jest.mock("../../notifications"); jest.mock("../../notifications");
jest.mock("../../../../common/utils/downloadFile", () => ({ jest.mock("../../../../common/utils/downloadFile", () => ({
@ -55,6 +49,7 @@ describe("Extensions", () => {
let installFromInput: jest.MockedFunction<InstallFromInput>; let installFromInput: jest.MockedFunction<InstallFromInput>;
let extensionInstallationStateStore: ExtensionInstallationStateStore; let extensionInstallationStateStore: ExtensionInstallationStateStore;
let render: DiRender; let render: DiRender;
let deleteFileMock: jest.MockedFunction<DeleteFile>;
beforeEach(() => { beforeEach(() => {
const di = getDiForUnitTesting({ doGeneralOverrides: true }); const di = getDiForUnitTesting({ doGeneralOverrides: true });
@ -62,18 +57,14 @@ describe("Extensions", () => {
di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data"); di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data");
di.override(directoryForDownloadsInjectable, () => "some-directory-for-downloads"); di.override(directoryForDownloadsInjectable, () => "some-directory-for-downloads");
di.permitSideEffects(getConfigurationFileModelInjectable);
mockFs({
"some-directory-for-user-data": {},
});
render = renderFor(di); render = renderFor(di);
installFromInput = jest.fn(); installFromInput = jest.fn();
di.override(installFromInputInjectable, () => installFromInput); di.override(installFromInputInjectable, () => installFromInput);
deleteFileMock = jest.fn();
di.override(deleteFileInjectable, () => deleteFileMock);
extensionLoader = di.inject(extensionLoaderInjectable); extensionLoader = di.inject(extensionLoaderInjectable);
extensionDiscovery = di.inject(extensionDiscoveryInjectable); extensionDiscovery = di.inject(extensionDiscoveryInjectable);
extensionInstallationStateStore = di.inject(extensionInstallationStateStoreInjectable); extensionInstallationStateStore = di.inject(extensionInstallationStateStoreInjectable);
@ -95,10 +86,6 @@ describe("Extensions", () => {
extensionDiscovery.uninstallExtension = jest.fn(() => Promise.resolve()); extensionDiscovery.uninstallExtension = jest.fn(() => Promise.resolve());
}); });
afterEach(() => {
mockFs.restore();
});
it("disables uninstall and disable buttons while uninstalling", async () => { it("disables uninstall and disable buttons while uninstalling", async () => {
extensionDiscovery.isLoaded = true; extensionDiscovery.isLoaded = true;
@ -138,7 +125,7 @@ describe("Extensions", () => {
const resolveInstall = observable.box(false); const resolveInstall = observable.box(false);
(fse.unlink as jest.MockedFunction<typeof fse.unlink>).mockReturnValue(Promise.resolve()); deleteFileMock.mockReturnValue(Promise.resolve());
installFromInput.mockImplementation(async (input) => { installFromInput.mockImplementation(async (input) => {
expect(input).toBe("https://test.extensionurl/package.tgz"); expect(input).toBe("https://test.extensionurl/package.tgz");

View File

@ -65,17 +65,23 @@ const NonInjectedAnimate = (propsAndDeps: AnimateProps & Dependencies) => {
setShowClassNameEnter(true); setShowClassNameEnter(true);
onEnterHandler(); onEnterHandler();
}); });
return noop;
} else if (isVisible) { } else if (isVisible) {
setShowClassNameLeave(true); setShowClassNameLeave(true);
onLeaveHandler(); onLeaveHandler();
// Cleanup after duration // Cleanup after duration
setTimeout(() => { const handle = setTimeout(() => {
setIsVisible(false); setIsVisible(false);
setShowClassNameEnter(false); setShowClassNameEnter(false);
setShowClassNameLeave(false); setShowClassNameLeave(false);
}, leaveDuration); }, leaveDuration);
return () => clearTimeout(handle);
} }
return noop;
}, [enter]); }, [enter]);
if (!isVisible) { if (!isVisible) {

View File

@ -6,9 +6,6 @@ import React from "react";
import "@testing-library/jest-dom/extend-expect"; import "@testing-library/jest-dom/extend-expect";
import { render } from "@testing-library/react"; import { render } from "@testing-library/react";
import { RenderDelay } from "../render-delay"; import { RenderDelay } from "../render-delay";
import { mockWindow } from "../../../../../__mocks__/windowMock";
mockWindow();
describe("<RenderDelay/>", () => { describe("<RenderDelay/>", () => {
it("renders w/o errors", () => { it("renders w/o errors", () => {

View File

@ -0,0 +1,9 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getGlobalOverride } from "../../../common/test-utils/get-global-override";
import cancelIdleCallbackInjectable from "./cancel-idle-callback.injectable";
export default getGlobalOverride(cancelIdleCallbackInjectable, () => () => {});

View File

@ -0,0 +1,15 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
export type CancelIdleCallback = (handle: number) => void;
const cancelIdleCallbackInjectable = getInjectable({
id: "cancel-idle-callback",
instantiate: (): CancelIdleCallback => window.cancelIdleCallback,
causesSideEffects: true,
});
export default cancelIdleCallbackInjectable;

View File

@ -3,42 +3,54 @@
* Licensed under MIT License. See LICENSE in root directory for more information. * Licensed under MIT License. See LICENSE in root directory for more information.
*/ */
import React from "react"; import React, { useEffect, useState } from "react";
import { makeObservable, observable } from "mobx";
import { observer } from "mobx-react";
import type { SingleOrMany } from "../../utils"; import type { SingleOrMany } from "../../utils";
import type { RequestIdleCallback } from "./request-idle-callback.injectable";
import type { CancelIdleCallback } from "./cancel-idle-callback.injectable";
import { withInjectables } from "@ogre-tools/injectable-react";
import cancelIdleCallbackInjectable from "./cancel-idle-callback.injectable";
import requestIdleCallbackInjectable from "./request-idle-callback.injectable";
export interface RenderDelayProps { export interface RenderDelayProps {
placeholder?: React.ReactNode; placeholder?: React.ReactNode;
children: SingleOrMany<React.ReactNode>; children: SingleOrMany<React.ReactNode>;
} }
@observer interface Dependencies {
export class RenderDelay extends React.Component<RenderDelayProps> { requestIdleCallback: RequestIdleCallback;
@observable isVisible = false; cancelIdleCallback: CancelIdleCallback;
constructor(props: RenderDelayProps) {
super(props);
makeObservable(this);
}
componentDidMount() {
const guaranteedFireTime = 1000;
window.requestIdleCallback(this.showContents, { timeout: guaranteedFireTime });
}
componentWillUnmount() {
window.cancelIdleCallback(this.showContents);
}
showContents = () => this.isVisible = true;
render() {
if (!this.isVisible) {
return this.props.placeholder || null;
}
return this.props.children;
}
} }
const NonInjectedRenderDelay = (props: RenderDelayProps & Dependencies) => {
const {
cancelIdleCallback,
requestIdleCallback,
children,
placeholder,
} = props;
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const handle = requestIdleCallback(() => setIsVisible(true), { timeout: 1000 });
return () => cancelIdleCallback(handle);
}, []);
return (
<>
{
isVisible
? placeholder ?? null
: children
}
</>
);
};
export const RenderDelay = withInjectables<Dependencies, RenderDelayProps>(NonInjectedRenderDelay, {
getProps: (di, props) => ({
...props,
cancelIdleCallback: di.inject(cancelIdleCallbackInjectable),
requestIdleCallback: di.inject(requestIdleCallbackInjectable),
}),
});

View File

@ -0,0 +1,9 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getGlobalOverride } from "../../../common/test-utils/get-global-override";
import requestIdleCallbackInjectable from "./request-idle-callback.injectable";
export default getGlobalOverride(requestIdleCallbackInjectable, () => (callback) => callback());

View File

@ -0,0 +1,15 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
export type RequestIdleCallback = (callback: () => void, options: { timeout: number }) => number;
const requestIdleCallbackInjectable = getInjectable({
id: "request-idle-callback",
instantiate: (): RequestIdleCallback => window.requestIdleCallback,
causesSideEffects: true,
});
export default requestIdleCallbackInjectable;

View File

@ -20,10 +20,6 @@ import fileSystemProvisionerStoreInjectable from "../extensions/extension-loader
import type { FileSystemProvisionerStore } from "../extensions/extension-loader/file-system-provisioner-store/file-system-provisioner-store"; import type { FileSystemProvisionerStore } from "../extensions/extension-loader/file-system-provisioner-store/file-system-provisioner-store";
import userStoreInjectable from "../common/user-store/user-store.injectable"; import userStoreInjectable from "../common/user-store/user-store.injectable";
import type { UserStore } from "../common/user-store"; import type { UserStore } from "../common/user-store";
import getAbsolutePathInjectable from "../common/path/get-absolute-path.injectable";
import { getAbsolutePathFake } from "../common/test-utils/get-absolute-path-fake";
import joinPathsInjectable from "../common/path/join-paths.injectable";
import { joinPathsFake } from "../common/test-utils/join-paths-fake";
import hotbarStoreInjectable from "../common/hotbars/store.injectable"; import hotbarStoreInjectable from "../common/hotbars/store.injectable";
import terminalSpawningPoolInjectable from "./components/dock/terminal/terminal-spawning-pool.injectable"; import terminalSpawningPoolInjectable from "./components/dock/terminal/terminal-spawning-pool.injectable";
import hostedClusterIdInjectable from "./cluster-frame-context/hosted-cluster-id.injectable"; import hostedClusterIdInjectable from "./cluster-frame-context/hosted-cluster-id.injectable";
@ -117,9 +113,6 @@ export const getDiForUnitTesting = (
di.override(terminalSpawningPoolInjectable, () => document.createElement("div")); di.override(terminalSpawningPoolInjectable, () => document.createElement("div"));
di.override(hostedClusterIdInjectable, () => undefined); di.override(hostedClusterIdInjectable, () => undefined);
di.override(getAbsolutePathInjectable, () => getAbsolutePathFake);
di.override(joinPathsInjectable, () => joinPathsFake);
di.override(historyInjectable, () => createMemoryHistory()); di.override(historyInjectable, () => createMemoryHistory());
di.override(legacyOnChannelListenInjectable, () => () => noop); di.override(legacyOnChannelListenInjectable, () => () => noop);

5
types/dom.d.ts vendored
View File

@ -8,9 +8,4 @@ declare global {
interface Element { interface Element {
scrollIntoViewIfNeeded?(opt_center?: boolean): void; scrollIntoViewIfNeeded?(opt_center?: boolean): void;
} }
interface Window {
requestIdleCallback(callback: () => void, options: { timeout: number });
cancelIdleCallback(callback: () => void);
}
} }