From 96f7fb52e618de54863dc362553ba5958cbbb6f6 Mon Sep 17 00:00:00 2001 From: lstocchi Date: Wed, 17 Jan 2024 11:36:53 +0100 Subject: [PATCH] fix: add ci for test, lint, format and typecheck Signed-off-by: lstocchi --- .github/workflows/pr-check.yaml | 87 ++++++++ package.json | 8 +- .../src/managers/applicationManager.ts | 139 +++++++------ packages/backend/src/managers/gitManager.ts | 17 +- packages/backend/src/models/AIConfig.ts | 35 ++-- packages/backend/src/playground.ts | 38 ++-- .../src/registries/RecipeStatusRegistry.ts | 10 +- .../backend/src/registries/TaskRegistry.ts | 5 +- packages/backend/src/studio-api-impl.ts | 45 +++-- packages/backend/src/studio.spec.ts | 4 +- packages/backend/src/studio.ts | 18 +- packages/backend/src/utils/pathUtils.ts | 6 +- .../backend/src/utils/recipeStatusUtils.ts | 29 ++- packages/frontend/package.json | 1 + packages/frontend/src/pages/Model.svelte | 2 +- packages/frontend/src/stores/local-models.ts | 10 +- packages/frontend/src/utils/client.ts | 5 +- .../frontend/src/utils/versionControlUtils.ts | 10 +- packages/shared/MessageProxy.ts | 159 --------------- .../shared/__mocks__/@podman-desktop/api.js | 24 +++ packages/shared/models/ICategory.ts | 5 - packages/shared/models/IRecipe.ts | 13 -- .../shared/{ => src}/MessageProxy.spec.ts | 27 ++- packages/shared/src/MessageProxy.ts | 186 ++++++++++++++++++ packages/shared/{ => src}/StudioAPI.ts | 15 +- packages/shared/src/models/ICategory.ts | 5 + .../{ => src}/models/ILocalModelInfo.ts | 0 .../shared/{ => src}/models/IModelInfo.ts | 2 +- .../shared/{ => src}/models/IModelResponse.ts | 0 packages/shared/src/models/IRecipe.ts | 13 ++ .../shared/{ => src}/models/IRecipeStatus.ts | 2 +- packages/shared/{ => src}/models/ITask.ts | 4 +- packages/shared/tsconfig.json | 27 +++ packages/shared/vite.config.js | 61 ++++++ packages/shared/vitest.config.js | 36 ++++ types/podman-desktop-api.d.ts | 2 + yarn.lock | 61 +++++- 37 files changed, 714 insertions(+), 397 deletions(-) create mode 100644 .github/workflows/pr-check.yaml delete mode 100644 packages/shared/MessageProxy.ts create mode 100644 packages/shared/__mocks__/@podman-desktop/api.js delete mode 100644 packages/shared/models/ICategory.ts delete mode 100644 packages/shared/models/IRecipe.ts rename packages/shared/{ => src}/MessageProxy.spec.ts (73%) create mode 100644 packages/shared/src/MessageProxy.ts rename packages/shared/{ => src}/StudioAPI.ts (75%) create mode 100644 packages/shared/src/models/ICategory.ts rename packages/shared/{ => src}/models/ILocalModelInfo.ts (100%) rename packages/shared/{ => src}/models/IModelInfo.ts (91%) rename packages/shared/{ => src}/models/IModelResponse.ts (100%) create mode 100644 packages/shared/src/models/IRecipe.ts rename packages/shared/{ => src}/models/IRecipeStatus.ts (83%) rename packages/shared/{ => src}/models/ITask.ts (51%) create mode 100644 packages/shared/tsconfig.json create mode 100644 packages/shared/vite.config.js create mode 100644 packages/shared/vitest.config.js diff --git a/.github/workflows/pr-check.yaml b/.github/workflows/pr-check.yaml new file mode 100644 index 000000000..69bbf8836 --- /dev/null +++ b/.github/workflows/pr-check.yaml @@ -0,0 +1,87 @@ +# +# Copyright (C) 2024 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 + +name: pr-check + +on: [pull_request] + +jobs: + lint-format-unit: + name: linter, formatters and unit tests / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 40 + strategy: + fail-fast: false + matrix: + os: [windows-2022, ubuntu-22.04] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 18 + + - name: Get yarn cache directory path (Windows) + if: ${{ matrix.os=='windows-2022' }} + id: yarn-cache-dir-path-windows + run: echo "dir=$(yarn cache dir)" >> ${env:GITHUB_OUTPUT} + + - name: Get yarn cache directory path (mac/Linux) + if: ${{ matrix.os=='ubuntu-22.04'}} + id: yarn-cache-dir-path-unix + run: echo "dir=$(yarn cache dir)" >> ${GITHUB_OUTPUT} + + - uses: actions/cache@v3 + if: ${{ matrix.os=='windows-2022' }} + id: yarn-cache-windows + with: + path: ${{ steps.yarn-cache-dir-path-windows.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - uses: actions/cache@v3 + if: ${{ matrix.os=='ubuntu-22.04'}} + id: yarn-cache-unix + with: + path: ${{ steps.yarn-cache-dir-path-unix.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: Execute yarn + run: yarn --frozen-lockfile --network-timeout 180000 + + - name: Run linter + run: yarn lint:check + + - name: Run formatter + run: yarn format:check + + - name: Run unit tests + run: yarn test:unit + + - name: Run typecheck + run: yarn typecheck + + # Check we don't have changes in git + - name: Check no changes in git + if: ${{ matrix.os=='ubuntu-22.04'}} + run: | + if ! git diff --exit-code; then + echo "Found changes in git" + exit 1 + fi \ No newline at end of file diff --git a/package.json b/package.json index fe94fdbbf..62547666a 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,13 @@ "format:fix": "prettier --write \"**/src/**/*.ts\"", "lint:check": "eslint . --ext js,ts,tsx", "lint:fix": "eslint . --fix --ext js,ts,tsx", - "test:backend": "vitest run -r packages/backend --passWithNoTests --coverage" + "test:backend": "vitest run -r packages/backend --passWithNoTests --coverage", + "test:shared": "vitest run -r packages/shared --passWithNoTests --coverage", + "test:unit": "npm run test:backend && npm run test:shared", + "typecheck:shared": "tsc --noEmit -p packages/shared/tsconfig.json", + "typecheck:frontend": "tsc --noEmit -p packages/frontend/tsconfig.json", + "typecheck:backend": "tsc --noEmit -p packages/backend/tsconfig.json", + "typecheck": "npm run typecheck:shared && npm run typecheck:frontend && npm run typecheck:backend" }, "devDependencies": { "@typescript-eslint/eslint-plugin": "^6.16.0", diff --git a/packages/backend/src/managers/applicationManager.ts b/packages/backend/src/managers/applicationManager.ts index 39effa785..96f177e36 100644 --- a/packages/backend/src/managers/applicationManager.ts +++ b/packages/backend/src/managers/applicationManager.ts @@ -1,21 +1,22 @@ -import { Recipe } from '@shared/models/IRecipe'; +import type { Recipe } from '@shared/src/models/IRecipe'; import { arch } from 'node:os'; -import { GitManager } from './gitManager'; +import type { GitManager } from './gitManager'; import os from 'os'; import fs from 'fs'; import * as https from 'node:https'; import * as path from 'node:path'; -import { containerEngine, ExtensionContext, provider } from '@podman-desktop/api'; -import { RecipeStatusRegistry } from '../registries/RecipeStatusRegistry'; -import { AIConfig, parseYaml } from '../models/AIConfig'; -import { Task } from '@shared/models/ITask'; +import { containerEngine, type ExtensionContext, provider } from '@podman-desktop/api'; +import type { RecipeStatusRegistry } from '../registries/RecipeStatusRegistry'; +import type { AIConfig } from '../models/AIConfig'; +import { parseYaml } from '../models/AIConfig'; +import type { Task } from '@shared/src/models/ITask'; import { RecipeStatusUtils } from '../utils/recipeStatusUtils'; import { getParentDirectory } from '../utils/pathUtils'; -import type { LocalModelInfo } from '@shared/models/ILocalModelInfo'; +import type { LocalModelInfo } from '@shared/src/models/ILocalModelInfo'; // TODO: Need to be configured export const AI_STUDIO_FOLDER = path.join('podman-desktop', 'ai-studio'); -export const CONFIG_FILENAME = "ai-studio.yaml"; +export const CONFIG_FILENAME = 'ai-studio.yaml'; interface DownloadModelResult { result: 'ok' | 'failed'; @@ -25,7 +26,11 @@ interface DownloadModelResult { export class ApplicationManager { readonly homeDirectory: string; // todo: make configurable - constructor(private git: GitManager, private recipeStatusRegistry: RecipeStatusRegistry, private extensionContext: ExtensionContext) { + constructor( + private git: GitManager, + private recipeStatusRegistry: RecipeStatusRegistry, + private extensionContext: ExtensionContext, + ) { this.homeDirectory = os.homedir(); } @@ -41,19 +46,19 @@ export class ApplicationManager { name: 'Checkout repository', state: 'loading', labels: { - 'git': 'checkout', + git: 'checkout', }, - } + }; taskUtil.setTask(checkoutTask); // We might already have the repository cloned - if(fs.existsSync(localFolder) && fs.statSync(localFolder).isDirectory()) { + if (fs.existsSync(localFolder) && fs.statSync(localFolder).isDirectory()) { // Update checkout state checkoutTask.name = 'Checkout repository (cached).'; checkoutTask.state = 'success'; } else { // Create folder - fs.mkdirSync(localFolder, {recursive: true}); + fs.mkdirSync(localFolder, { recursive: true }); // Clone the repository console.log(`Cloning repository ${recipe.repository} in ${localFolder}.`); @@ -70,27 +75,27 @@ export class ApplicationManager { id: 'loading-config', name: 'Loading configuration', state: 'loading', - } + }; taskUtil.setTask(loadingConfiguration); let configFile: string; - if(recipe.config !== undefined) { + if (recipe.config !== undefined) { configFile = path.join(localFolder, recipe.config); } else { configFile = path.join(localFolder, CONFIG_FILENAME); } - if(!fs.existsSync(configFile)) { + if (!fs.existsSync(configFile)) { loadingConfiguration.state = 'error'; taskUtil.setTask(loadingConfiguration); throw new Error(`The file located at ${configFile} does not exist.`); } // If the user configured the config as a directory we check for "ai-studio.yaml" inside. - if(fs.statSync(configFile).isDirectory()) { + if (fs.statSync(configFile).isDirectory()) { const tmpPath = path.join(configFile, CONFIG_FILENAME); // If it has the ai-studio.yaml we use it. - if(fs.existsSync(tmpPath)) { + if (fs.existsSync(tmpPath)) { configFile = tmpPath; } } @@ -108,7 +113,7 @@ export class ApplicationManager { throw new Error('Cannot load configuration file.'); } - // Mark as success + // Mark as success. loadingConfiguration.state = 'success'; taskUtil.setTask(loadingConfiguration); @@ -117,49 +122,50 @@ export class ApplicationManager { const connection = connections[0]; // Filter the containers based on architecture - const filteredContainers = aiConfig.application.containers - .filter((container) => container.arch === undefined || container.arch === arch()) + const filteredContainers = aiConfig.application.containers.filter( + container => container.arch === undefined || container.arch === arch(), + ); // Download first model available (if exist) - if(recipe.models && recipe.models.length > 0) { + if (recipe.models && recipe.models.length > 0) { const model = recipe.models[0]; taskUtil.setTask({ id: model.id, state: 'loading', name: `Downloading model ${model.name}`, labels: { - "model-pulling": model.id, - } + 'model-pulling': model.id, + }, }); - await this.downloadModelMain(model.id, model.url, taskUtil) + await this.downloadModelMain(model.id, model.url, taskUtil); } - filteredContainers.forEach((container) => { + filteredContainers.forEach(container => { taskUtil.setTask({ id: container.name, state: 'loading', name: `Building ${container.name}`, - }) + }); }); // Promise all the build images return Promise.all( - filteredContainers.map((container) => - { - // We use the parent directory of our configFile as the rootdir, then we append the contextDir provided - const context = path.join(getParentDirectory(configFile), container.contextdir); - console.log(`Application Manager using context ${context} for container ${container.name}`); - - // Ensure the context provided exist otherwise throw an Error - if(!fs.existsSync(context)) { - console.error('The context provided does not exist.'); - taskUtil.setTaskState(container.name, 'error'); - throw new Error('Context configured does not exist.'); - } + filteredContainers.map(container => { + // We use the parent directory of our configFile as the rootdir, then we append the contextDir provided + const context = path.join(getParentDirectory(configFile), container.contextdir); + console.log(`Application Manager using context ${context} for container ${container.name}`); + + // Ensure the context provided exist otherwise throw an Error + if (!fs.existsSync(context)) { + console.error('The context provided does not exist.'); + taskUtil.setTaskState(container.name, 'error'); + throw new Error('Context configured does not exist.'); + } - let isErrored = false; - return containerEngine.buildImage( + let isErrored = false; + return containerEngine + .buildImage( context, container.containerfile, `${container.name}:latest`, @@ -168,24 +174,23 @@ export class ApplicationManager { (event, data) => { // todo: do something with the event if (event === 'error' || (event === 'finish' && data !== '')) { - console.error(`Something went wrong while building the image: `, data); + console.error('Something went wrong while building the image: ', data); taskUtil.setTaskState(container.name, 'error'); isErrored = true; } if (event === 'finish' && !isErrored) { - taskUtil.setTaskState(container.name, 'success'); + taskUtil.setTaskState(container.name, 'success'); } - } - ).catch(err => { - console.error(`Something went wrong while building the image: `, err); + }, + ) + .catch((err: unknown) => { + console.error('Something went wrong while building the image: ', err); taskUtil.setTaskState(container.name, 'error'); }); - } - ) - ) + }), + ); } - downloadModelMain(modelId: string, url: string, taskUtil: RecipeStatusUtils, destFileName?: string): Promise { return new Promise((resolve, reject) => { const downloadCallback = (result: DownloadModelResult) => { @@ -194,33 +199,39 @@ export class ApplicationManager { resolve(''); } else { taskUtil.setTaskState(modelId, 'error'); - reject(result.error) + reject(result.error); } - } + }; - if(fs.existsSync(destFileName)) { + if (fs.existsSync(destFileName)) { taskUtil.setTaskState(modelId, 'success'); taskUtil.setTaskProgress(modelId, 100); return; } - this.downloadModel(modelId, url, taskUtil, downloadCallback, destFileName) - }) + this.downloadModel(modelId, url, taskUtil, downloadCallback, destFileName); + }); } - private downloadModel(modelId: string, url: string, taskUtil: RecipeStatusUtils, callback: (message: DownloadModelResult) => void, destFileName?: string) { + private downloadModel( + modelId: string, + url: string, + taskUtil: RecipeStatusUtils, + callback: (message: DownloadModelResult) => void, + destFileName?: string, + ) { const destDir = path.join(this.homeDirectory, AI_STUDIO_FOLDER, 'models', modelId); if (!fs.existsSync(destDir)) { fs.mkdirSync(destDir, { recursive: true }); } if (!destFileName) { - destFileName = path.basename(url); + destFileName = path.basename(url); } const destFile = path.resolve(destDir, destFileName); const file = fs.createWriteStream(destFile); let totalFileSize = 0; let progress = 0; - https.get(url, (resp) => { + https.get(url, resp => { if (resp.headers.location) { this.downloadModel(modelId, resp.headers.location, taskUtil, callback, destFileName); return; @@ -230,9 +241,9 @@ export class ApplicationManager { } } - resp.on('data', (chunk) => { + resp.on('data', chunk => { progress += chunk.length; - const progressValue = progress * 100 / totalFileSize; + const progressValue = (progress * 100) / totalFileSize; taskUtil.setTaskProgress(modelId, progressValue); @@ -240,19 +251,19 @@ export class ApplicationManager { //this.sendProgress(progressValue); if (progressValue === 100) { callback({ - result: 'ok' + result: 'ok', }); } }); file.on('finish', () => { file.close(); }); - file.on('error', (e) => { + file.on('error', e => { callback({ result: 'failed', error: e.message, }); - }) + }); resp.pipe(file); }); } @@ -272,7 +283,7 @@ export class ApplicationManager { result.push({ id: d.name, file: modelEntries[0], - }) + }); } return result; } diff --git a/packages/backend/src/managers/gitManager.ts b/packages/backend/src/managers/gitManager.ts index e4b5a3ee8..a5004acb3 100644 --- a/packages/backend/src/managers/gitManager.ts +++ b/packages/backend/src/managers/gitManager.ts @@ -1,13 +1,12 @@ -import simpleGit, { type SimpleGit } from "simple-git"; - +import simpleGit, { type SimpleGit } from 'simple-git'; export class GitManager { - private readonly simpleGit: SimpleGit - constructor() { - this.simpleGit = simpleGit(); - } + private readonly simpleGit: SimpleGit; + constructor() { + this.simpleGit = simpleGit(); + } - async cloneRepository(repository: string, targetDirectory: string) { - return this.simpleGit.clone(repository, targetDirectory); - } + async cloneRepository(repository: string, targetDirectory: string) { + return this.simpleGit.clone(repository, targetDirectory); + } } diff --git a/packages/backend/src/models/AIConfig.ts b/packages/backend/src/models/AIConfig.ts index 1f1fd92c7..097f5230d 100644 --- a/packages/backend/src/models/AIConfig.ts +++ b/packages/backend/src/models/AIConfig.ts @@ -9,38 +9,35 @@ export interface ContainerConfig { } export interface AIConfig { application: { - containers: ContainerConfig[] - } + containers: ContainerConfig[]; + }; } export function isString(value: unknown): value is string { - return value !== undefined && value !== null && typeof value === 'string' || value instanceof String; - + return (value !== undefined && value !== null && typeof value === 'string') || value instanceof String; } export function assertString(value: unknown): string { - if(isString(value)) - return value; + if (isString(value)) return value; throw new Error('value not a string'); } export function parseYaml(raw: string, defaultArch: string): AIConfig { const aiStudioConfig = jsYaml.load(raw); - const application = aiStudioConfig?.["application"]; - if(!application) - throw new Error('AIConfig has bad formatting.'); + const application = aiStudioConfig?.['application']; + if (!application) throw new Error('AIConfig has bad formatting.'); - const containers: unknown[] = application["containers"] ?? []; + const containers: unknown[] = application['containers'] ?? []; return { application: { - containers: containers.map((container) => ({ - arch: isString(container["arch"])?container["arch"]:defaultArch, - modelService: container["model-service"] === true, - containerfile: isString(container["containerfile"])?container["containerfile"]:undefined, - contextdir: assertString(container["contextdir"]), - name: assertString(container["name"]), - })) - } - } + containers: containers.map(container => ({ + arch: isString(container['arch']) ? container['arch'] : defaultArch, + modelService: container['model-service'] === true, + containerfile: isString(container['containerfile']) ? container['containerfile'] : undefined, + contextdir: assertString(container['contextdir']), + name: assertString(container['name']), + })), + }, + }; } diff --git a/packages/backend/src/playground.ts b/packages/backend/src/playground.ts index 612e4f31f..0669b049d 100644 --- a/packages/backend/src/playground.ts +++ b/packages/backend/src/playground.ts @@ -1,6 +1,6 @@ import { provider, containerEngine, type ProviderContainerConnection, type ImageInfo } from '@podman-desktop/api'; -import { LocalModelInfo } from '@shared/models/ILocalModelInfo'; -import { ModelResponse } from '@shared/models/IModelResponse'; +import type { LocalModelInfo } from '@shared/src/models/ILocalModelInfo'; +import type { ModelResponse } from '@shared/src/models/IModelResponse'; import path from 'node:path'; import * as http from 'node:http'; @@ -17,7 +17,7 @@ function findFirstProvider(): ProviderContainerConnection | undefined { export class PlayGroundManager { async selectImage(connection: ProviderContainerConnection, image: string): Promise { - const images = (await containerEngine.listImages()).filter(im => im.RepoTags && im.RepoTags.some(tag => tag === image)); + const images = (await containerEngine.listImages()).filter(im => im.RepoTags?.some(tag => tag === image)); return images.length > 0 ? images[0] : undefined; } @@ -51,10 +51,10 @@ export class PlayGroundManager { PortBindings: { '8080/tcp': [ { - HostPort: '9000' - } - ] - } + HostPort: '9000', + }, + ], + }, }, Cmd: ['--models-path', '/models', '--context-size', '700', '--threads', '4'], }); @@ -71,32 +71,32 @@ export class PlayGroundManager { async askPlayground(modelInfo: LocalModelInfo, prompt: string): Promise { return new Promise(resolve => { - let post_data = JSON.stringify({ - "model": modelInfo.file, - "prompt": prompt, - "temperature": 0.7 + const post_data = JSON.stringify({ + model: modelInfo.file, + prompt: prompt, + temperature: 0.7, }); - let post_options: http.RequestOptions = { + const post_options: http.RequestOptions = { host: 'localhost', port: '9000', path: '/v1/completions', method: 'POST', headers: { - 'Content-Type': 'application/json' - } + 'Content-Type': 'application/json', + }, }; - let post_req = http.request(post_options, function (res) { + const post_req = http.request(post_options, function (res) { res.setEncoding('utf8'); const chunks = []; - res.on('data', (data) => chunks.push(data)); + res.on('data', data => chunks.push(data)); res.on('end', () => { - let resBody = chunks.join(); + const resBody = chunks.join(); + const result = JSON.parse(resBody); + console.log('result', result); switch (res.headers['content-type']) { case 'application/json': - const result = JSON.parse(resBody); - console.log('result', result); resolve(result as ModelResponse); break; } diff --git a/packages/backend/src/registries/RecipeStatusRegistry.ts b/packages/backend/src/registries/RecipeStatusRegistry.ts index 8f636a6f3..63820c2f9 100644 --- a/packages/backend/src/registries/RecipeStatusRegistry.ts +++ b/packages/backend/src/registries/RecipeStatusRegistry.ts @@ -1,15 +1,15 @@ -import { RecipeStatus } from '@shared/models/IRecipeStatus'; -import { TaskRegistry } from './TaskRegistry'; +import type { RecipeStatus } from '@shared/src/models/IRecipeStatus'; +import type { TaskRegistry } from './TaskRegistry'; export class RecipeStatusRegistry { private statuses: Map = new Map(); - constructor(private taskRegistry: TaskRegistry) { } + constructor(private taskRegistry: TaskRegistry) {} setStatus(recipeId: string, status: RecipeStatus) { // Update the TaskRegistry - if(status.tasks && status.tasks.length > 0) { - status.tasks.map((task) => this.taskRegistry.set(task)); + if (status.tasks && status.tasks.length > 0) { + status.tasks.map(task => this.taskRegistry.set(task)); } this.statuses.set(recipeId, status); } diff --git a/packages/backend/src/registries/TaskRegistry.ts b/packages/backend/src/registries/TaskRegistry.ts index 699fa3a4f..1508a831c 100644 --- a/packages/backend/src/registries/TaskRegistry.ts +++ b/packages/backend/src/registries/TaskRegistry.ts @@ -1,11 +1,10 @@ -import { Task } from '@shared/models/ITask'; +import type { Task } from '@shared/src/models/ITask'; export class TaskRegistry { private tasks: Map = new Map(); getTasksByLabel(label: string): Task[] { - return Array.from(this.tasks.values()) - .filter((task) => label in (task.labels || {})) + return Array.from(this.tasks.values()).filter(task => label in (task.labels || {})); } set(task: Task) { diff --git a/packages/backend/src/studio-api-impl.ts b/packages/backend/src/studio-api-impl.ts index 5831c7ac7..2585efdc1 100644 --- a/packages/backend/src/studio-api-impl.ts +++ b/packages/backend/src/studio-api-impl.ts @@ -1,17 +1,17 @@ -import type { StudioAPI } from '@shared/StudioAPI'; -import { Category } from '@shared/models/ICategory'; -import { Recipe } from '@shared/models/IRecipe'; +import type { StudioAPI } from '@shared/src/StudioAPI'; +import type { Category } from '@shared/src/models/ICategory'; +import type { Recipe } from '@shared/src/models/IRecipe'; import content from './ai.json'; -import { AI_STUDIO_FOLDER, ApplicationManager } from './managers/applicationManager'; -import { RecipeStatusRegistry } from './registries/RecipeStatusRegistry'; -import { RecipeStatus } from '@shared/models/IRecipeStatus'; -import { ModelInfo } from '@shared/models/IModelInfo'; -import { TaskRegistry } from './registries/TaskRegistry'; -import { Task } from '@shared/models/ITask'; -import { Studio } from './studio'; +import type { ApplicationManager } from './managers/applicationManager'; +import { AI_STUDIO_FOLDER } from './managers/applicationManager'; +import type { RecipeStatusRegistry } from './registries/RecipeStatusRegistry'; +import type { RecipeStatus } from '@shared/src/models/IRecipeStatus'; +import type { ModelInfo } from '@shared/src/models/IModelInfo'; +import type { TaskRegistry } from './registries/TaskRegistry'; +import type { Task } from '@shared/src/models/ITask'; import * as path from 'node:path'; -import { ModelResponse } from '@shared/models/IModelResponse'; -import { PlayGroundManager } from './playground'; +import type { ModelResponse } from '@shared/src/models/IModelResponse'; +import type { PlayGroundManager } from './playground'; import * as podmanDesktopApi from '@podman-desktop/api'; export const RECENT_CATEGORY_ID = 'recent-category'; @@ -25,13 +25,11 @@ export class StudioApiImpl implements StudioAPI { ) {} async openURL(url: string): Promise { - return await podmanDesktopApi.env.openExternal( - podmanDesktopApi.Uri.parse(url) - ); + return await podmanDesktopApi.env.openExternal(podmanDesktopApi.Uri.parse(url)); } async getPullingStatus(recipeId: string): Promise { - return this.recipeStatusRegistry.getStatus(recipeId); + return this.recipeStatusRegistry.getStatus(recipeId); } async ping(): Promise { @@ -65,7 +63,8 @@ export class StudioApiImpl implements StudioAPI { throw new Error('several models with same id'); } - async searchRecipes(query: string): Promise { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async searchRecipes(_query: string): Promise { return []; // todo: not implemented } @@ -75,9 +74,7 @@ export class StudioApiImpl implements StudioAPI { console.log('StudioApiImpl recipe', recipe); // Do not wait for the pull application, run it separately - new Promise(() => { - this.applicationManager.pullApplication(recipe); - }); + this.applicationManager.pullApplication(recipe).catch((error: unknown) => console.warn(error)); return Promise.resolve(undefined); } @@ -98,7 +95,13 @@ export class StudioApiImpl implements StudioAPI { throw new Error('model not found'); } - const modelPath = path.resolve(this.applicationManager.homeDirectory, AI_STUDIO_FOLDER, 'models', modelId, localModelInfo[0].file); + const modelPath = path.resolve( + this.applicationManager.homeDirectory, + AI_STUDIO_FOLDER, + 'models', + modelId, + localModelInfo[0].file, + ); await this.playgroundManager.startPlayground(modelId, modelPath); } diff --git a/packages/backend/src/studio.spec.ts b/packages/backend/src/studio.spec.ts index b2bbd3f60..59fe67152 100644 --- a/packages/backend/src/studio.spec.ts +++ b/packages/backend/src/studio.spec.ts @@ -39,8 +39,8 @@ vi.mock('@podman-desktop/api', async () => { createWebviewPanel: () => ({ webview: { html: '', - onDidReceiveMessage: vi.fn() - } + onDidReceiveMessage: vi.fn(), + }, }), }, }; diff --git a/packages/backend/src/studio.ts b/packages/backend/src/studio.ts index eb13c103b..1cc5ed91d 100644 --- a/packages/backend/src/studio.ts +++ b/packages/backend/src/studio.ts @@ -18,16 +18,13 @@ import type { ExtensionContext, WebviewOptions, WebviewPanel } from '@podman-desktop/api'; import { Uri, window } from '@podman-desktop/api'; -import { RpcExtension } from '@shared/MessageProxy'; +import { RpcExtension } from '@shared/src/MessageProxy'; import { StudioApiImpl } from './studio-api-impl'; import { ApplicationManager } from './managers/applicationManager'; import { GitManager } from './managers/gitManager'; import { RecipeStatusRegistry } from './registries/RecipeStatusRegistry'; import * as fs from 'node:fs'; -import * as https from 'node:https'; -import * as path from 'node:path'; -import type { LocalModelInfo } from '@shared/models/ILocalModelInfo'; import { TaskRegistry } from './registries/TaskRegistry'; import { PlayGroundManager } from './playground'; @@ -95,17 +92,8 @@ export class Studio { const gitManager = new GitManager(); const taskRegistry = new TaskRegistry(); const recipeStatusRegistry = new RecipeStatusRegistry(taskRegistry); - const applicationManager = new ApplicationManager( - gitManager, - recipeStatusRegistry, - this.#extensionContext, - ) - this.studioApi = new StudioApiImpl( - applicationManager, - recipeStatusRegistry, - taskRegistry, - this.playgroundManager, - ); + const applicationManager = new ApplicationManager(gitManager, recipeStatusRegistry, this.#extensionContext); + this.studioApi = new StudioApiImpl(applicationManager, recipeStatusRegistry, taskRegistry, this.playgroundManager); // Register the instance this.rpcExtension.registerInstance(StudioApiImpl, this.studioApi); } diff --git a/packages/backend/src/utils/pathUtils.ts b/packages/backend/src/utils/pathUtils.ts index 3907f6d35..0d60707d3 100644 --- a/packages/backend/src/utils/pathUtils.ts +++ b/packages/backend/src/utils/pathUtils.ts @@ -1,11 +1,9 @@ import path from 'path'; export function getParentDirectory(filePath: string): string { -// Normalize the path to handle different platform-specific separators + // Normalize the path to handle different platform-specific separators const normalizedPath = path.normalize(filePath); // Get the directory name using path.dirname - const parentDirectory = path.dirname(normalizedPath); - - return parentDirectory; + return path.dirname(normalizedPath); } diff --git a/packages/backend/src/utils/recipeStatusUtils.ts b/packages/backend/src/utils/recipeStatusUtils.ts index 93c3ddc46..e04c0cbca 100644 --- a/packages/backend/src/utils/recipeStatusUtils.ts +++ b/packages/backend/src/utils/recipeStatusUtils.ts @@ -1,13 +1,15 @@ -import type { RecipeStatus, RecipeStatusState } from '@shared/models/IRecipeStatus'; -import type { Task, TaskState } from '@shared/models/ITask'; -import { RecipeStatusRegistry } from '../registries/RecipeStatusRegistry'; - +import type { RecipeStatus, RecipeStatusState } from '@shared/src/models/IRecipeStatus'; +import type { Task, TaskState } from '@shared/src/models/ITask'; +import type { RecipeStatusRegistry } from '../registries/RecipeStatusRegistry'; export class RecipeStatusUtils { private tasks: Map = new Map(); private state: RecipeStatusState = 'loading'; - constructor(private recipeId: string, private recipeStatusRegistry: RecipeStatusRegistry) {} + constructor( + private recipeId: string, + private recipeStatusRegistry: RecipeStatusRegistry, + ) {} update() { this.recipeStatusRegistry.setStatus(this.recipeId, this.toRecipeStatus()); @@ -21,30 +23,27 @@ export class RecipeStatusUtils { setTask(task: Task) { this.tasks.set(task.id, task); - if(task.state === 'error') - this.setStatus('error'); + if (task.state === 'error') this.setStatus('error'); this.update(); } setTaskState(taskId: string, state: TaskState) { - if(!this.tasks.has(taskId)) - throw new Error('task not found.'); + if (!this.tasks.has(taskId)) throw new Error('task not found.'); const task = this.tasks.get(taskId); this.setTask({ ...task, - state: state - }) + state: state, + }); } setTaskProgress(taskId: string, value: number) { - if(!this.tasks.has(taskId)) - throw new Error('task not found.'); + if (!this.tasks.has(taskId)) throw new Error('task not found.'); const task = this.tasks.get(taskId); this.setTask({ ...task, progress: value, - }) + }); } toRecipeStatus(): RecipeStatus { @@ -52,6 +51,6 @@ export class RecipeStatusUtils { recipeId: this.recipeId, state: this.state, tasks: Array.from(this.tasks.values()), - } + }; } } diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 894bb51e5..04973b6a6 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -19,6 +19,7 @@ "svelte-fa": "^3.0.4", "svelte-markdown": "^0.4.1", "@tailwindcss/typography": "^0.5.10", + "@testing-library/jest-dom": "^6.2.0", "@fortawesome/fontawesome-free": "^6.5.1", "@fortawesome/free-brands-svg-icons": "^6.5.1", "@fortawesome/free-solid-svg-icons": "^6.5.1", diff --git a/packages/frontend/src/pages/Model.svelte b/packages/frontend/src/pages/Model.svelte index 081f33776..453a28890 100644 --- a/packages/frontend/src/pages/Model.svelte +++ b/packages/frontend/src/pages/Model.svelte @@ -3,7 +3,7 @@ import NavPage from '/@/lib/NavPage.svelte'; import Tab from '/@/lib/Tab.svelte'; import Route from '/@/Route.svelte'; import MarkdownRenderer from '/@/lib/markdown/MarkdownRenderer.svelte'; -import type { ModelInfo } from '@shared/models/IModelInfo'; +import type { ModelInfo } from '@shared/src/models/IModelInfo'; import { studioClient } from '../utils/client'; import { onMount } from 'svelte'; import ModelPlayground from './ModelPlayground.svelte'; diff --git a/packages/frontend/src/stores/local-models.ts b/packages/frontend/src/stores/local-models.ts index 4b0888d56..c40e32b87 100644 --- a/packages/frontend/src/stores/local-models.ts +++ b/packages/frontend/src/stores/local-models.ts @@ -1,10 +1,10 @@ -import type { ModelInfo } from '@shared/models/IModelInfo'; +import type { ModelInfo } from '@shared/src/models/IModelInfo'; import type { Readable } from 'svelte/store'; import { readable } from 'svelte/store'; import { studioClient } from '/@/utils/client'; -export const localModels: Readable = readable([], (set) => { - studioClient.getLocalModels().then(v => { - set(v); - }) +export const localModels: Readable = readable([], set => { + studioClient.getLocalModels().then(v => { + set(v); + }); }); diff --git a/packages/frontend/src/utils/client.ts b/packages/frontend/src/utils/client.ts index ad8013d11..35da0fe0c 100644 --- a/packages/frontend/src/utils/client.ts +++ b/packages/frontend/src/utils/client.ts @@ -1,6 +1,5 @@ -import type { StudioAPI } from '@shared/StudioAPI'; -import { RpcBrowser } from '@shared/MessageProxy'; -import type { ModelInfo } from '@shared/models/IModelInfo'; +import type { StudioAPI } from '@shared/src/StudioAPI'; +import { RpcBrowser } from '@shared/src/MessageProxy'; export const RECENT_CATEGORY_ID = 'recent-category'; const podmanDesktopApi = acquirePodmanDesktopApi(); diff --git a/packages/frontend/src/utils/versionControlUtils.ts b/packages/frontend/src/utils/versionControlUtils.ts index 204de6903..59ca9e1df 100644 --- a/packages/frontend/src/utils/versionControlUtils.ts +++ b/packages/frontend/src/utils/versionControlUtils.ts @@ -1,11 +1,9 @@ -const GITHUB_PREFIX = "https://github.com/"; +const GITHUB_PREFIX = 'https://github.com/'; export const getDisplayName = (link: string | undefined): string => { - if(link === undefined) - return "?"; + if (link === undefined) return '?'; - if(link.startsWith(GITHUB_PREFIX)) - return link.substring(GITHUB_PREFIX.length) + if (link.startsWith(GITHUB_PREFIX)) return link.substring(GITHUB_PREFIX.length); return link; -} +}; diff --git a/packages/shared/MessageProxy.ts b/packages/shared/MessageProxy.ts deleted file mode 100644 index 75c20ae98..000000000 --- a/packages/shared/MessageProxy.ts +++ /dev/null @@ -1,159 +0,0 @@ -import type { PodmanDesktopApi } from '../../types/podman-desktop-api'; -import type { Webview } from '@podman-desktop/api'; -import { Studio } from '../backend/src/studio'; - -export interface IMessage { - id: number; - channel: string; -} - -export interface IMessageRequest extends IMessage{ - args: any[]; -} - -export interface IMessageResponse extends IMessageRequest { - status: 'error' | 'success'; - error?: string | undefined; - body: any; -} - -export function isMessageRequest(content: any): content is IMessageRequest { - return content !== undefined && content !== null && 'id' in content && 'channel' in content; -} - -export function isMessageResponse(content: any): content is IMessageResponse { - return isMessageRequest(content) && 'status' in content -} - -export class RpcExtension { - methods: Map Promise> = new Map(); - - constructor(private webview: Webview) { - this.init(); - } - - init() { - this.webview.onDidReceiveMessage(async (message: any) => { - if(!isMessageRequest(message)) { - console.error("Received incompatible message.", message); - return; - } - - if(!this.methods.has(message.channel)) { - console.error(`Trying to call on an unknown channel ${message.channel}. Available: ${Array.from(this.methods.keys())}`); - throw new Error('channel does not exist.'); - } - - try { - const result = await this.methods.get(message.channel)?.(...message.args); - this.webview.postMessage({ - id: message.id, - channel: message.channel, - body: result, - status: 'success', - } as IMessageResponse); - } catch (e) { - this.webview.postMessage({ - id: message.id, - channel: message.channel, - body: undefined, - error: `Something went wrong on channel ${message.channel}: ${String(e)}` - } as IMessageResponse); - } - }); - } - - registerInstance(classType: { new (...args: any[]): T }, instance: T) { - const methodNames = Object.getOwnPropertyNames(classType.prototype) - .filter(name => name !== 'constructor' && typeof instance[name] === 'function'); - - methodNames.forEach(name => { - const method = instance[name].bind(instance); - this.register(name, method); - }); - } - - register(channel: string, method: (body: any) => any) { - this.methods.set(channel, method); - } -} - -export class RpcBrowser { - counter: number = 0; - promises: Map any, reject: (value: unknown) => void}> = new Map(); - - getUniqueId(): number { - return ++this.counter; - } - - constructor(private window: Window, private api: PodmanDesktopApi) { - this.init(); - } - - init() { - this.window.addEventListener('message', (event: MessageEvent) => { - const message = event.data; - if(!isMessageResponse(message)) { - console.error("Received incompatible message.", message); - return; - } - - if(!this.promises.has(message.id)) { - console.error('Unknown message id.'); - return; - } - - const { resolve, reject } = this.promises.get(message.id) || {}; - - if(message.status === 'error') { - reject?.(message.error) - } else { - resolve?.(message.body); - } - this.promises.delete(message.id); - }) - } - - getProxy(): T { - const thisRef = this; - const proxyHandler: ProxyHandler = { - get(target, prop, receiver) { - if (typeof prop === 'string') { - return (...args: any[]) => { - const channel = prop.toString(); - return thisRef.invoke(channel, ...args); - }; - } - return Reflect.get(target, prop, receiver); - }, - }; - - return new Proxy({}, proxyHandler) as T; - } - - async invoke(channel: string, ...args: any[]): Promise { - // Generate a unique id for the request - const requestId = this.getUniqueId(); - - // Post the message - this.api.postMessage({ - id: requestId, - channel: channel, - args: args - } as IMessageRequest); - - // Add some timeout - setTimeout(() => { - const {reject} = this.promises.get(requestId) || {}; - if(!reject) - return; - reject(new Error('Timeout')); - this.promises.delete(requestId); - }, 5000); - - // Create a Promise - return new Promise((resolve, reject) => { - this.promises.set(requestId, {resolve, reject}); - }) - } -} diff --git a/packages/shared/__mocks__/@podman-desktop/api.js b/packages/shared/__mocks__/@podman-desktop/api.js new file mode 100644 index 000000000..1fb06f662 --- /dev/null +++ b/packages/shared/__mocks__/@podman-desktop/api.js @@ -0,0 +1,24 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +/** + * Mock the extension API for vitest. + * This file is referenced from vitest.config.js file. + */ +const plugin = {}; +module.exports = plugin; diff --git a/packages/shared/models/ICategory.ts b/packages/shared/models/ICategory.ts deleted file mode 100644 index f4e8c639c..000000000 --- a/packages/shared/models/ICategory.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface Category { - id: string, - name: string, - description?: string, -} diff --git a/packages/shared/models/IRecipe.ts b/packages/shared/models/IRecipe.ts deleted file mode 100644 index bf2e7d83d..000000000 --- a/packages/shared/models/IRecipe.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { ModelInfo } from './IModelInfo'; - -export interface Recipe { - id?: string, - name: string, - categories: string[], - description: string, - icon?: string, - repository: string, - readme: string, - config?: string, - models?: ModelInfo[], -} diff --git a/packages/shared/MessageProxy.spec.ts b/packages/shared/src/MessageProxy.spec.ts similarity index 73% rename from packages/shared/MessageProxy.spec.ts rename to packages/shared/src/MessageProxy.spec.ts index c25b5cb41..595ce0fa6 100644 --- a/packages/shared/MessageProxy.spec.ts +++ b/packages/shared/src/MessageProxy.spec.ts @@ -1,6 +1,6 @@ import { test, expect, beforeAll } from 'vitest'; import { RpcBrowser, RpcExtension } from './MessageProxy'; -import { PodmanDesktopApi } from '../../types/podman-desktop-api'; +import type { PodmanDesktopApi } from '../../../types/podman-desktop-api'; import type { Webview } from '@podman-desktop/api'; let webview: Webview; @@ -8,39 +8,38 @@ let window: Window; let api: PodmanDesktopApi; beforeAll(() => { - - let windowListener: (message: any) => void; - let webviewListener: (message: any) => void; + let windowListener: (message: unknown) => void; + let webviewListener: (message: unknown) => void; webview = { - onDidReceiveMessage: (listener: (message: any) => void) => { + onDidReceiveMessage: (listener: (message: unknown) => void) => { webviewListener = listener; }, - postMessage: async (message: any): Promise => { - windowListener({data: message} as MessageEvent); - } + postMessage: async (message: unknown): Promise => { + windowListener({ data: message } as MessageEvent); + }, } as unknown as Webview; window = { - addEventListener: (channel: string, listener: (message: any) => void) => { + addEventListener: (channel: string, listener: (message: unknown) => void) => { expect(channel).toBe('message'); windowListener = listener; }, } as unknown as Window; api = { - postMessage: (message: any) => { + postMessage: (message: unknown) => { webviewListener(message); }, } as unknown as PodmanDesktopApi; -}) +}); test('Test register channel no argument', async () => { const rpcExtension = new RpcExtension(webview); const rpcBrowser = new RpcBrowser(window, api); rpcExtension.register('ping', () => { - return 'pong'; + return Promise.resolve('pong'); }); expect(await rpcBrowser.invoke('ping')).toBe('pong'); @@ -51,7 +50,7 @@ test('Test register channel one argument', async () => { const rpcBrowser = new RpcBrowser(window, api); rpcExtension.register('double', (value: number) => { - return value*2; + return Promise.resolve(value * 2); }); expect(await rpcBrowser.invoke('double', 4)).toBe(8); @@ -62,7 +61,7 @@ test('Test register channel multiple arguments', async () => { const rpcBrowser = new RpcBrowser(window, api); rpcExtension.register('sum', (...args: number[]) => { - return args.reduce((prev, current) => prev+current, 0) + return Promise.resolve(args.reduce((prev, current) => prev + current, 0)); }); expect(await rpcBrowser.invoke('sum', 1, 2, 3, 4, 5)).toBe(15); diff --git a/packages/shared/src/MessageProxy.ts b/packages/shared/src/MessageProxy.ts new file mode 100644 index 000000000..b1d22a9bf --- /dev/null +++ b/packages/shared/src/MessageProxy.ts @@ -0,0 +1,186 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type { PodmanDesktopApi } from '../../../types/podman-desktop-api'; +import type { Webview } from '@podman-desktop/api'; + +export interface IMessage { + id: number; + channel: string; +} + +export interface IMessageRequest extends IMessage { + args: unknown[]; +} + +export interface IMessageResponse extends IMessageRequest { + status: 'error' | 'success'; + error?: string; + body: unknown; +} + +export function isMessageRequest(content: unknown): content is IMessageRequest { + return ( + content !== undefined && content !== null && typeof content === 'object' && 'id' in content && 'channel' in content + ); +} + +export function isMessageResponse(content: unknown): content is IMessageResponse { + return isMessageRequest(content) && 'status' in content; +} + +export class RpcExtension { + methods: Map Promise> = new Map(); + + constructor(private webview: Webview) { + this.init(); + } + + init() { + this.webview.onDidReceiveMessage(async (message: unknown) => { + if (!isMessageRequest(message)) { + console.error('Received incompatible message.', message); + return; + } + + if (!this.methods.has(message.channel)) { + console.error( + `Trying to call on an unknown channel ${message.channel}. Available: ${Array.from(this.methods.keys())}`, + ); + throw new Error('channel does not exist.'); + } + + try { + const result = await this.methods.get(message.channel)?.(...message.args); + this.webview.postMessage({ + id: message.id, + channel: message.channel, + body: result, + status: 'success', + } as IMessageResponse); + } catch (e) { + this.webview.postMessage({ + id: message.id, + channel: message.channel, + body: undefined, + error: `Something went wrong on channel ${message.channel}: ${String(e)}`, + } as IMessageResponse); + } + }); + } + + registerInstance(classType: { new (...args: any[]): T }, instance: T) { + const methodNames = Object.getOwnPropertyNames(classType.prototype).filter( + name => name !== 'constructor' && typeof instance[name as keyof T] === 'function', + ); + + methodNames.forEach(name => { + const method = (instance[name as keyof T] as any).bind(instance); + this.register(name, method); + }); + } + + register(channel: string, method: (body: any) => Promise) { + this.methods.set(channel, method); + } +} + +export class RpcBrowser { + counter: number = 0; + promises: Map unknown; reject: (value: unknown) => void }> = new Map(); + + getUniqueId(): number { + return ++this.counter; + } + + constructor( + private window: Window, + private api: PodmanDesktopApi, + ) { + this.init(); + } + + init() { + this.window.addEventListener('message', (event: MessageEvent) => { + const message = event.data; + if (!isMessageResponse(message)) { + console.error('Received incompatible message.', message); + return; + } + + if (!this.promises.has(message.id)) { + console.error('Unknown message id.'); + return; + } + + const { resolve, reject } = this.promises.get(message.id) || {}; + + if (message.status === 'error') { + reject?.(message.error); + } else { + resolve?.(message.body); + } + this.promises.delete(message.id); + }); + } + + getProxy(): T { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const thisRef = this; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const proxyHandler: ProxyHandler = { + get(target, prop, receiver) { + if (typeof prop === 'string') { + return (...args: unknown[]) => { + const channel = prop.toString(); + return thisRef.invoke(channel, ...args); + }; + } + return Reflect.get(target, prop, receiver); + }, + }; + + return new Proxy({}, proxyHandler) as T; + } + + async invoke(channel: string, ...args: unknown[]): Promise { + // Generate a unique id for the request + const requestId = this.getUniqueId(); + + // Post the message + this.api.postMessage({ + id: requestId, + channel: channel, + args: args, + } as IMessageRequest); + + // Add some timeout + setTimeout(() => { + const { reject } = this.promises.get(requestId) || {}; + if (!reject) return; + reject(new Error('Timeout')); + this.promises.delete(requestId); + }, 5000); + + // Create a Promise + return new Promise((resolve, reject) => { + this.promises.set(requestId, { resolve, reject }); + }); + } +} diff --git a/packages/shared/StudioAPI.ts b/packages/shared/src/StudioAPI.ts similarity index 75% rename from packages/shared/StudioAPI.ts rename to packages/shared/src/StudioAPI.ts index 31b3c59f3..332b7f59b 100644 --- a/packages/shared/StudioAPI.ts +++ b/packages/shared/src/StudioAPI.ts @@ -1,9 +1,9 @@ -import type { Recipe } from '@shared/models/IRecipe'; -import type { Category } from '@shared/models/ICategory'; -import { RecipeStatus } from '@shared/models/IRecipeStatus'; -import { ModelInfo } from '@shared/models/IModelInfo'; -import { ModelResponse } from '@shared/models/IModelResponse'; -import { Task } from './models/ITask'; +import type { Recipe } from './models/IRecipe'; +import type { Category } from './models/ICategory'; +import type { RecipeStatus } from './models/IRecipeStatus'; +import type { ModelInfo } from './models/IModelInfo'; +import type { ModelResponse } from './models/IModelResponse'; +import type { Task } from './models/ITask'; export abstract class StudioAPI { abstract ping(): Promise; @@ -13,7 +13,7 @@ export abstract class StudioAPI { abstract getRecipeById(recipeId: string): Promise; abstract getModelById(modelId: string): Promise; abstract searchRecipes(query: string): Promise; - abstract getPullingStatus(recipeId: string): Promise + abstract getPullingStatus(recipeId: string): Promise; abstract pullApplication(recipeId: string): Promise; abstract openURL(url: string): Promise; /** @@ -30,4 +30,3 @@ export abstract class StudioAPI { */ abstract getTasksByLabel(label: string): Promise; } - diff --git a/packages/shared/src/models/ICategory.ts b/packages/shared/src/models/ICategory.ts new file mode 100644 index 000000000..7980277dd --- /dev/null +++ b/packages/shared/src/models/ICategory.ts @@ -0,0 +1,5 @@ +export interface Category { + id: string; + name: string; + description?: string; +} diff --git a/packages/shared/models/ILocalModelInfo.ts b/packages/shared/src/models/ILocalModelInfo.ts similarity index 100% rename from packages/shared/models/ILocalModelInfo.ts rename to packages/shared/src/models/ILocalModelInfo.ts diff --git a/packages/shared/models/IModelInfo.ts b/packages/shared/src/models/IModelInfo.ts similarity index 91% rename from packages/shared/models/IModelInfo.ts rename to packages/shared/src/models/IModelInfo.ts index 0273fe1e5..5fb64808c 100644 --- a/packages/shared/models/IModelInfo.ts +++ b/packages/shared/src/models/IModelInfo.ts @@ -2,7 +2,7 @@ export interface ModelInfo { id: string; name: string; description: string; - hw:string; + hw: string; registry: string; popularity: number; license: string; diff --git a/packages/shared/models/IModelResponse.ts b/packages/shared/src/models/IModelResponse.ts similarity index 100% rename from packages/shared/models/IModelResponse.ts rename to packages/shared/src/models/IModelResponse.ts diff --git a/packages/shared/src/models/IRecipe.ts b/packages/shared/src/models/IRecipe.ts new file mode 100644 index 000000000..1d972f329 --- /dev/null +++ b/packages/shared/src/models/IRecipe.ts @@ -0,0 +1,13 @@ +import type { ModelInfo } from './IModelInfo'; + +export interface Recipe { + id?: string; + name: string; + categories: string[]; + description: string; + icon?: string; + repository: string; + readme: string; + config?: string; + models?: ModelInfo[]; +} diff --git a/packages/shared/models/IRecipeStatus.ts b/packages/shared/src/models/IRecipeStatus.ts similarity index 83% rename from packages/shared/models/IRecipeStatus.ts rename to packages/shared/src/models/IRecipeStatus.ts index 280051221..dad911de1 100644 --- a/packages/shared/models/IRecipeStatus.ts +++ b/packages/shared/src/models/IRecipeStatus.ts @@ -1,4 +1,4 @@ -import { Task } from './ITask'; +import type { Task } from './ITask'; export type RecipeStatusState = 'none' | 'loading' | 'pulled' | 'running' | 'error'; diff --git a/packages/shared/models/ITask.ts b/packages/shared/src/models/ITask.ts similarity index 51% rename from packages/shared/models/ITask.ts rename to packages/shared/src/models/ITask.ts index c26df28d0..689db0d45 100644 --- a/packages/shared/models/ITask.ts +++ b/packages/shared/src/models/ITask.ts @@ -1,9 +1,9 @@ -export type TaskState = 'loading' | 'error' | 'success' +export type TaskState = 'loading' | 'error' | 'success'; export interface Task { id: string; state: TaskState; progress?: number; name: string; - labels?: {[id: string]: string} + labels?: { [id: string]: string }; } diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 000000000..8557feb7e --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "@tsconfig/svelte/tsconfig.json", + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "strict": true, + "resolveJsonModule": true, + "preserveValueImports": false, + "baseUrl": ".", + /** + * Typecheck JS in `.svelte` and `.js` files by default. + * Disable checkJs if you'd like to use dynamic types in JS. + * Note that setting allowJs false does not prevent the use + * of JS in `.svelte` files. + */ + "allowJs": true, + "checkJs": true + }, + "include": [ + "src/**/*.d.ts", + "src/**/*.ts", + "src/**/*.js", + "src/**/*.svelte", + "types/*.d.ts", + "../../types/**/*.d.ts", + ] +} diff --git a/packages/shared/vite.config.js b/packages/shared/vite.config.js new file mode 100644 index 000000000..ff6ce5c60 --- /dev/null +++ b/packages/shared/vite.config.js @@ -0,0 +1,61 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import {join} from 'path'; +import {builtinModules} from 'module'; + +const PACKAGE_ROOT = __dirname; + +/** + * @type {import('vite').UserConfig} + * @see https://vitejs.dev/config/ + */ +const config = { + mode: process.env.MODE, + root: PACKAGE_ROOT, + envDir: process.cwd(), + resolve: { + alias: { + '/@/': join(PACKAGE_ROOT, 'src') + '/', + }, + }, + build: { + sourcemap: 'inline', + target: 'esnext', + outDir: 'dist', + assetsDir: '.', + minify: process.env.MODE === 'production' ? 'esbuild' : false, + lib: { + entry: 'src/extension.ts', + formats: ['cjs'], + }, + rollupOptions: { + external: [ + '@podman-desktop/api', + ...builtinModules.flatMap(p => [p, `node:${p}`]), + ], + output: { + entryFileNames: '[name].js', + }, + }, + emptyOutDir: true, + reportCompressedSize: false, + }, +}; + +export default config; diff --git a/packages/shared/vitest.config.js b/packages/shared/vitest.config.js new file mode 100644 index 000000000..7fd3420e9 --- /dev/null +++ b/packages/shared/vitest.config.js @@ -0,0 +1,36 @@ +/********************************************************************** + * Copyright (C) 2023 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import path from 'node:path'; +import { join } from 'path'; + +const PACKAGE_ROOT = __dirname; + +const config = { + test: { + include: ['**/*.{test,spec}.?(c|m)[jt]s?(x)', '../shared/**/*.{test,spec}.?(c|m)[jt]s?(x)'] + }, + resolve: { + alias: { + '@podman-desktop/api': path.resolve(__dirname, '__mocks__/@podman-desktop/api.js'), + '/@/': join(PACKAGE_ROOT, 'src') + '/', + }, + }, +}; + +export default config; diff --git a/types/podman-desktop-api.d.ts b/types/podman-desktop-api.d.ts index 33276d33a..fd69ff26b 100644 --- a/types/podman-desktop-api.d.ts +++ b/types/podman-desktop-api.d.ts @@ -1,4 +1,6 @@ +// eslint-disable-next-line etc/no-commented-out-code // podman-desktop-api.d.ts +/* eslint-disable @typescript-eslint/no-explicit-any */ declare global { export interface PodmanDesktopApi { diff --git a/yarn.lock b/yarn.lock index 9a3ef707c..3bfc8e3a8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7,6 +7,11 @@ resolved "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz" integrity sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA== +"@adobe/css-tools@^4.3.2": + version "4.3.2" + resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.3.2.tgz#a6abc715fb6884851fca9dad37fc34739a04fd11" + integrity sha512-DA5a1C0gD/pLOvhv33YMrbf2FK3oUzwNl9oOJqE4XVjuEtt6XIakRcsd7eLiOSPkp1kTRQGICTA8cKra/vFbjw== + "@alloc/quick-lru@^5.2.0": version "5.2.0" resolved "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz" @@ -59,6 +64,13 @@ dependencies: regenerator-runtime "^0.14.0" +"@babel/runtime@^7.9.2": + version "7.23.8" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.8.tgz#8ee6fe1ac47add7122902f257b8ddf55c898f650" + integrity sha512-Y7KbAP984rn1VGMbGqKmBLio9V7y5Je9GvU4rQPCPinCyNfUcToxIXl06d59URp/F3LwinvODxab5N/G6qggkw== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/types@^7.23.3": version "7.23.6" resolved "https://registry.npmjs.org/@babel/types/-/types-7.23.6.tgz" @@ -503,6 +515,20 @@ lz-string "^1.5.0" pretty-format "^27.0.2" +"@testing-library/jest-dom@^6.2.0": + version "6.2.0" + resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-6.2.0.tgz#b572bd5cd6b29314487bac7ba393188e4987b4f7" + integrity sha512-+BVQlJ9cmEn5RDMUS8c2+TU6giLvzaHZ8sU/x0Jj7fk+6/46wPdwlgOPcpxS17CjcanBi/3VmGMqVr2rmbUmNw== + dependencies: + "@adobe/css-tools" "^4.3.2" + "@babel/runtime" "^7.9.2" + aria-query "^5.0.0" + chalk "^3.0.0" + css.escape "^1.5.1" + dom-accessibility-api "^0.6.3" + lodash "^4.17.15" + redent "^3.0.0" + "@testing-library/svelte@^4.0.5": version "4.0.5" resolved "https://registry.npmjs.org/@testing-library/svelte/-/svelte-4.0.5.tgz" @@ -965,7 +991,7 @@ aria-query@5.1.3: dependencies: deep-equal "^2.0.5" -aria-query@^5.3.0: +aria-query@^5.0.0, aria-query@^5.3.0: version "5.3.0" resolved "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz" integrity sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A== @@ -1175,6 +1201,14 @@ chalk@^2.4.2: escape-string-regexp "^1.0.5" supports-color "^5.3.0" +chalk@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4" + integrity sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2: version "4.1.2" resolved "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz" @@ -1296,6 +1330,11 @@ css-tree@^2.3.1: mdn-data "2.0.30" source-map-js "^1.0.1" +css.escape@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb" + integrity sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg== + cssesc@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz" @@ -1431,6 +1470,11 @@ dom-accessibility-api@^0.5.9: resolved "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz" integrity sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg== +dom-accessibility-api@^0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz#993e925cc1d73f2c662e7d75dd5a5445259a8fd8" + integrity sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w== + eastasianwidth@^0.2.0: version "0.2.0" resolved "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz" @@ -2155,6 +2199,11 @@ imurmurhash@^0.1.4: resolved "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz" integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== +indent-string@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251" + integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== + inflight@^1.0.4: version "1.0.6" resolved "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz" @@ -2520,7 +2569,7 @@ lodash.merge@^4.6.2: resolved "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== -lodash@^4.17.21: +lodash@^4.17.15, lodash@^4.17.21: version "4.17.21" resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -3055,6 +3104,14 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" +redent@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f" + integrity sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg== + dependencies: + indent-string "^4.0.0" + strip-indent "^3.0.0" + regenerator-runtime@^0.14.0: version "0.14.1" resolved "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz"