Compare commits
234 Commits
Author | SHA1 | Date |
---|---|---|
|
7b333d6132 | |
|
260e009ee1 | |
|
7078043b31 | |
|
c9904b72ba | |
|
e758c2c7b5 | |
|
fe2803eedc | |
|
edb858b924 | |
|
bbad58e7e7 | |
|
4fe5d75d37 | |
|
e67ebb773d | |
|
0847ffd6fe | |
|
baa3f6fa16 | |
|
8aec87aec3 | |
|
ee838d4337 | |
|
1a65ce7309 | |
|
8ee204265c | |
|
d12fbe7077 | |
|
f2e50a9d96 | |
|
7c56784f17 | |
|
d7bef61a17 | |
|
dfb0d129a2 | |
|
d8b30d9056 | |
|
ec4a50926b | |
|
0f51ac9fb8 | |
|
95d855d5dc | |
|
92cd8d54a6 | |
|
383bc584bd | |
|
b201805cd8 | |
|
3ec5fa9d99 | |
|
26589bf7c0 | |
|
8f87998e72 | |
|
3dff4f9493 | |
|
38b8a27a61 | |
|
7758d94952 | |
|
bba111a748 | |
|
be7ca31004 | |
|
b7f5074429 | |
|
900c4478f6 | |
|
d191cdd38a | |
|
576830d438 | |
|
27d5f6e726 | |
|
16fd59fd1d | |
|
8a9df3c634 | |
|
c640a95eab | |
|
6bab97b462 | |
|
b8e566fd5f | |
|
8e6c393958 | |
|
6b468253f6 | |
|
8c7e735517 | |
|
34086a3688 | |
|
c05fd38183 | |
|
4070e3285f | |
|
fb7b8b16e1 | |
|
3523120f7a | |
|
12b59b0664 | |
|
5d35a1bc65 | |
|
ed1eabdbf3 | |
|
417f1d720b | |
|
b0964c71ef | |
|
c826b1dcae | |
|
5af2390540 | |
|
e360f59b81 | |
|
234dab8661 | |
|
224034c41f | |
|
6cfeb54a0c | |
|
00e6d29423 | |
|
f7988661dd | |
|
ce1dd81e96 | |
|
94d6d79b35 | |
|
c92c49dff6 | |
|
29ca0cfb36 | |
|
b1bca4a678 | |
|
55d1c38182 | |
|
26b8fa11fc | |
|
67903c3b56 | |
|
580504240e | |
|
73343765da | |
|
68ab9d6406 | |
|
43d010c8bf | |
|
f822443165 | |
|
33b38224d5 | |
|
17b78dce92 | |
|
6447b4210b | |
|
06accdeb27 | |
|
35f37e0a5c | |
|
255d4e5f04 | |
|
6c3cffbb95 | |
|
3e625d5145 | |
|
b066514e78 | |
|
163105e1a6 | |
|
fb680b7571 | |
|
5023a73f66 | |
|
44eee2f6c4 | |
|
ea15b7f90c | |
|
6164dd85c9 | |
|
b688fdf802 | |
|
a0edc0feba | |
|
7bd6a63827 | |
|
60fbd3a0fc | |
|
3c9b382e90 | |
|
d5829cdf80 | |
|
58076c5ed6 | |
|
fcc0981995 | |
|
9c5240fb93 | |
|
e7dc3b503d | |
|
31daa3137e | |
|
cb94c2d37c | |
|
a2a91f50d6 | |
|
7b3131edfc | |
|
5fe17f185e | |
|
275118513e | |
|
62728bd9b8 | |
|
2608209348 | |
|
2f54822df8 | |
|
957f45d51a | |
|
1bc1d2dacf | |
|
0bf3f8ffc8 | |
|
8b7966f7fb | |
|
3600dbd0ad | |
|
cbc9986a13 | |
|
62fcfee7db | |
|
75baaed2a0 | |
|
7d43bce9f2 | |
|
39468474c4 | |
|
b36b5d40c8 | |
|
25d8ee1903 | |
|
1f6d62d6a3 | |
|
b8583e962f | |
|
c1aadd8ee9 | |
|
759cabfcf6 | |
|
436b18838e | |
|
4aff54d462 | |
|
c7de6c232f | |
|
09ca443ac7 | |
|
d3aea619eb | |
|
7f872a09c8 | |
|
083c5b46c9 | |
|
8342b3443d | |
|
7a52ee3732 | |
|
35938dbaec | |
|
856028e86c | |
|
d183a3de98 | |
|
e7e5b7419b | |
|
422302f39a | |
|
70dc6a1374 | |
|
215a94433d | |
|
9438670d54 | |
|
e7bc3b272f | |
|
c7f53dbf67 | |
|
24e8471b65 | |
|
a360ab4c19 | |
|
a13ca53150 | |
|
c7de36867e | |
|
3e50e8e85e | |
|
40b5b8d3a8 | |
|
9176457e82 | |
|
8999037dec | |
|
047b1f21f1 | |
|
2b71a4916e | |
|
06866ef2ee | |
|
04103b7375 | |
|
018051441b | |
|
1b8d37c4a4 | |
|
cbee0effe8 | |
|
27adf640b2 | |
|
2b612a8698 | |
|
3cd32e83ce | |
|
098bc8c3ea | |
|
ad157bae31 | |
|
033dedbb69 | |
|
2770d10ad3 | |
|
66aeb14ca6 | |
|
c10cd56a73 | |
|
d400fabffd | |
|
36b9530c1f | |
|
719801c50a | |
|
300620618c | |
|
3a89f2051f | |
|
75a16a2ecb | |
|
9211096e6c | |
|
4f9ceed95d | |
|
fb265b174e | |
|
feafd739c6 | |
|
c0ceb3c1ea | |
|
c3b8a07518 | |
|
107d786be6 | |
|
385599e1de | |
|
24d5022140 | |
|
8ea3b2938b | |
|
051ccd6625 | |
|
fe89b97e65 | |
|
1cc19e4280 | |
|
9a3df58bdd | |
|
4a0408210f | |
|
dfe4e93e4f | |
|
737a31ec7d | |
|
7acf72d0f7 | |
|
d69d649273 | |
|
214065cd55 | |
|
cd8ccdd0bf | |
|
74ef2666ca | |
|
f953f01a8d | |
|
629fedb4b9 | |
|
4de191b0fe | |
|
e8728576f9 | |
|
e51cb90a5b | |
|
091e148f5c | |
|
ff1dc06d3b | |
|
eb21be7c99 | |
|
dfdc3d6e13 | |
|
1729873d84 | |
|
ac72327395 | |
|
6210a9016b | |
|
121b41c81f | |
|
715df73530 | |
|
3399bca071 | |
|
4ee9ef4ba5 | |
|
4366a091e4 | |
|
a5c151dc00 | |
|
e916fe89b9 | |
|
89fe6b34dc | |
|
15038b41d7 | |
|
db97acbaaa | |
|
6d856cffd7 | |
|
bdc90f4ceb | |
|
496d480cb8 | |
|
7a5c29ec22 | |
|
a3d4939358 | |
|
20a60f57ec | |
|
5152260847 | |
|
f3a9316470 | |
|
a4b853da42 | |
|
0f85d3f9ce | |
|
48d87e21f9 |
|
@ -26,14 +26,9 @@ on:
|
||||||
- 'packages/backend/src/assets/ai.json'
|
- 'packages/backend/src/assets/ai.json'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
fork:
|
podman_desktop_repo_args:
|
||||||
default: 'containers'
|
default: 'REPO=podman-desktop,FORK=podman-desktop,BRANCH=main'
|
||||||
description: 'Podman Desktop repo fork'
|
description: 'Podman Desktop repo fork and branch'
|
||||||
type: string
|
|
||||||
required: true
|
|
||||||
branch:
|
|
||||||
default: 'main'
|
|
||||||
description: 'Podman Desktop repo branch'
|
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
ext_repo_options:
|
ext_repo_options:
|
||||||
|
@ -72,24 +67,26 @@ on:
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
azure_vm_size:
|
azure_vm_size:
|
||||||
default: 'Standard_D8s_v4'
|
default: ''
|
||||||
description: 'Azure VM size (Standard_E4as_v5 is cheapest, 4core AMD, 32GB RAM)'
|
description: 'Azure VM size (Standard_E4as_v5 is cheapest, 4core AMD, 32GB RAM)'
|
||||||
type: choice
|
type: choice
|
||||||
required: true
|
required: false
|
||||||
options:
|
options:
|
||||||
|
- ''
|
||||||
- Standard_D8as_v5
|
- Standard_D8as_v5
|
||||||
- Standard_D8s_v4
|
- Standard_D8s_v4
|
||||||
- Standard_E8as_v5
|
- Standard_E8as_v5
|
||||||
- Standard_E4as_v5
|
- Standard_E4as_v5
|
||||||
|
mapt_params:
|
||||||
|
default: 'IMAGE=quay.io/redhat-developer/mapt,VERSION_TAG=v0.9.5,CPUS=4,MEMORY=32,EXCLUDED_REGIONS="westindia,centralindia,southindia,australiacentral,australiacentral2,australiaeast,australiasoutheast,southafricanorth,southafricawest"'
|
||||||
|
description: 'MAPT image, version tag, cpus and memory request, and excluded regions in format IMAGE=xxx,VERSION_TAG=xxx,CPUS=xxx,MEMORY=xxx,EXCLUDED_REGIONS=xxx'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
windows:
|
windows:
|
||||||
name: windows-${{ matrix.windows-version }}-${{ matrix.windows-featurepack }}
|
name: windows-${{ matrix.windows-version }}-${{ matrix.windows-featurepack }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
|
||||||
MAPT_VERSION: v0.7.4
|
|
||||||
MAPT_IMAGE: quay.io/redhat-developer/mapt
|
|
||||||
MAPT_EXCLUDED_REGIONS: 'westindia,centralindia,southindia,australiacentral,australiacentral2,australiaeast,australiasoutheast,southafricanorth,southafricawest'
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
|
@ -108,10 +105,10 @@ jobs:
|
||||||
version=$(curl https://raw.githubusercontent.com/containers/podman-desktop/main/extensions/podman/packages/extension/src/podman5.json | jq -r '.version')
|
version=$(curl https://raw.githubusercontent.com/containers/podman-desktop/main/extensions/podman/packages/extension/src/podman5.json | jq -r '.version')
|
||||||
echo "Default Podman Version from Podman Desktop: ${version}"
|
echo "Default Podman Version from Podman Desktop: ${version}"
|
||||||
echo "PD_PODMAN_VERSION=${version}" >> $GITHUB_ENV
|
echo "PD_PODMAN_VERSION=${version}" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Set the default env. variables
|
- name: Set the default env. variables
|
||||||
env:
|
env:
|
||||||
DEFAULT_FORK: 'containers'
|
DEFAULT_PODMAN_DESKTOP_REPO_ARGS: 'REPO=podman-desktop,FORK=podman-desktop,BRANCH=main'
|
||||||
DEFAULT_BRANCH: 'main'
|
|
||||||
DEFAULT_NPM_TARGET: 'test:e2e'
|
DEFAULT_NPM_TARGET: 'test:e2e'
|
||||||
DEFAULT_ENV_VARS: 'TEST_PODMAN_MACHINE=true,ELECTRON_ENABLE_INSPECT=true'
|
DEFAULT_ENV_VARS: 'TEST_PODMAN_MACHINE=true,ELECTRON_ENABLE_INSPECT=true'
|
||||||
DEFAULT_PODMAN_OPTIONS: 'INIT=1,START=1,ROOTFUL=1,NETWORKING=0'
|
DEFAULT_PODMAN_OPTIONS: 'INIT=1,START=1,ROOTFUL=1,NETWORKING=0'
|
||||||
|
@ -120,44 +117,75 @@ jobs:
|
||||||
DEFAULT_PODMAN_VERSION: "${{ env.PD_PODMAN_VERSION || '5.3.2' }}"
|
DEFAULT_PODMAN_VERSION: "${{ env.PD_PODMAN_VERSION || '5.3.2' }}"
|
||||||
DEFAULT_URL: "https://github.com/containers/podman/releases/download/v$DEFAULT_PODMAN_VERSION/podman-$DEFAULT_PODMAN_VERSION-setup.exe"
|
DEFAULT_URL: "https://github.com/containers/podman/releases/download/v$DEFAULT_PODMAN_VERSION/podman-$DEFAULT_PODMAN_VERSION-setup.exe"
|
||||||
DEFAULT_PDE2E_IMAGE_VERSION: 'v0.0.3-windows'
|
DEFAULT_PDE2E_IMAGE_VERSION: 'v0.0.3-windows'
|
||||||
DEFAULT_AZURE_VM_SIZE: 'Standard_D8s_v4'
|
DEFAULT_MAPT_PARAMS: "IMAGE=${{ vars.MAPT_IMAGE || 'quay.io/redhat-developer/mapt' }},VERSION_TAG=${{ vars.MAPT_VERSION_TAG || 'v0.9.5' }},CPUS=${{ vars.MAPT_CPUS || '4' }},MEMORY=${{ vars.MAPT_MEMORY || '32' }},EXCLUDED_REGIONS=\"${{ vars.MAPT_EXCLUDED_REGIONS || 'westindia,centralindia,southindia,australiacentral,australiacentral2,australiaeast,australiasoutheast,southafricanorth,southafricawest' }}\""
|
||||||
run: |
|
run: |
|
||||||
echo "FORK=${{ github.event.inputs.fork || env.DEFAULT_FORK }}" >> $GITHUB_ENV
|
|
||||||
echo "BRANCH=${{ github.event.inputs.branch || env.DEFAULT_BRANCH }}" >> $GITHUB_ENV
|
|
||||||
echo "NPM_TARGET=${{ github.event.inputs.npm_target || env.DEFAULT_NPM_TARGET }}" >> $GITHUB_ENV
|
echo "NPM_TARGET=${{ github.event.inputs.npm_target || env.DEFAULT_NPM_TARGET }}" >> $GITHUB_ENV
|
||||||
echo "ENV_VARS=${{ github.event.inputs.env_vars || env.DEFAULT_ENV_VARS }}" >> $GITHUB_ENV
|
echo "ENV_VARS=${{ github.event.inputs.env_vars || env.DEFAULT_ENV_VARS }}" >> $GITHUB_ENV
|
||||||
echo "PODMAN_URL=${{ github.event.inputs.podman_remote_url || env.DEFAULT_URL }}" >> $GITHUB_ENV
|
echo "PODMAN_URL=${{ github.event.inputs.podman_remote_url || env.DEFAULT_URL }}" >> $GITHUB_ENV
|
||||||
echo "PDE2E_IMAGE_VERSION=${{ github.event.inputs.pde2e_image_version || env.DEFAULT_PDE2E_IMAGE_VERSION }}" >> $GITHUB_ENV
|
echo "PDE2E_IMAGE_VERSION=${{ github.event.inputs.pde2e_image_version || env.DEFAULT_PDE2E_IMAGE_VERSION }}" >> $GITHUB_ENV
|
||||||
|
echo "${{ github.event.inputs.podman_desktop_repo_args || env.DEFAULT_PODMAN_DESKTOP_REPO_ARGS }}" | awk -F ',' \
|
||||||
|
'{for (i=1; i<=NF; i++) {split($i, kv, "="); print "PD_"kv[1]"="kv[2]}}' >> $GITHUB_ENV
|
||||||
echo "${{ github.event.inputs.ext_tests_options || env.DEFAULT_EXT_TESTS_OPTIONS }}" | awk -F ',' \
|
echo "${{ github.event.inputs.ext_tests_options || env.DEFAULT_EXT_TESTS_OPTIONS }}" | awk -F ',' \
|
||||||
'{for (i=1; i<=NF; i++) {split($i, kv, "="); print kv[1]"="kv[2]}}' >> $GITHUB_ENV
|
'{for (i=1; i<=NF; i++) {split($i, kv, "="); print kv[1]"="kv[2]}}' >> $GITHUB_ENV
|
||||||
echo "${{ github.event.inputs.podman_options || env.DEFAULT_PODMAN_OPTIONS }}" | awk -F ',' \
|
echo "${{ github.event.inputs.podman_options || env.DEFAULT_PODMAN_OPTIONS }}" | awk -F ',' \
|
||||||
'{for (i=1; i<=NF; i++) {split($i, kv, "="); print "PODMAN_"kv[1]"="kv[2]}}' >> $GITHUB_ENV
|
'{for (i=1; i<=NF; i++) {split($i, kv, "="); print "PODMAN_"kv[1]"="kv[2]}}' >> $GITHUB_ENV
|
||||||
echo "${{ github.event.inputs.ext_repo_options || env.DEFAULT_EXT_REPO_OPTIONS }}" | awk -F ',' \
|
echo "${{ github.event.inputs.ext_repo_options || env.DEFAULT_EXT_REPO_OPTIONS }}" | awk -F ',' \
|
||||||
'{for (i=1; i<=NF; i++) {split($i, kv, "="); print "EXT_"kv[1]"="kv[2]}}' >> $GITHUB_ENV
|
'{for (i=1; i<=NF; i++) {split($i, kv, "="); print "EXT_"kv[1]"="kv[2]}}' >> $GITHUB_ENV
|
||||||
echo "AZURE_VM_SIZE=${{ github.event.inputs.azure_vm_size || env.DEFAULT_AZURE_VM_SIZE }}" >> $GITHUB_ENV
|
echo "MAPT_VM_SIZE=${{ github.event.inputs.azure_vm_size || '' }}" >> $GITHUB_ENV
|
||||||
|
echo "${{ github.event.inputs.mapt_params || env.DEFAULT_MAPT_PARAMS }}" | awk -F ',' \
|
||||||
|
'{for (i=1; i<=NF; i++) {split($i, kv, "="); print "MAPT_"kv[1]"="kv[2]}}' >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Create instance
|
- name: Create instance
|
||||||
run: |
|
run: |
|
||||||
# Create instance
|
# Create instance
|
||||||
podman run -d --name windows-create --rm \
|
if [ -z "${{ env.MAPT_VM_SIZE }}" ]; then
|
||||||
-v ${PWD}:/workspace:z \
|
echo "MAPT_VM_SIZE is not set, using resources approach"
|
||||||
-e ARM_TENANT_ID=${{ secrets.ARM_TENANT_ID }} \
|
podman run -d --name windows-create --rm \
|
||||||
-e ARM_SUBSCRIPTION_ID=${{ secrets.ARM_SUBSCRIPTION_ID }} \
|
-v ${PWD}:/workspace:z \
|
||||||
-e ARM_CLIENT_ID=${{ secrets.ARM_CLIENT_ID }} \
|
-e ARM_TENANT_ID=${{ secrets.ARM_TENANT_ID }} \
|
||||||
-e ARM_CLIENT_SECRET='${{ secrets.ARM_CLIENT_SECRET }}' \
|
-e ARM_SUBSCRIPTION_ID=${{ secrets.ARM_SUBSCRIPTION_ID }} \
|
||||||
${{ env.MAPT_IMAGE }}:${{ env.MAPT_VERSION }} azure \
|
-e ARM_CLIENT_ID=${{ secrets.ARM_CLIENT_ID }} \
|
||||||
windows create \
|
-e ARM_CLIENT_SECRET='${{ secrets.ARM_CLIENT_SECRET }}' \
|
||||||
--project-name 'windows-desktop' \
|
--user 0 \
|
||||||
--backed-url 'file:///workspace' \
|
${{ env.MAPT_IMAGE }}:${{ env.MAPT_VERSION_TAG }} azure \
|
||||||
--conn-details-output '/workspace' \
|
windows create \
|
||||||
--windows-version '${{ matrix.windows-version }}' \
|
--project-name 'windows-desktop' \
|
||||||
--windows-featurepack '${{ matrix.windows-featurepack }}' \
|
--backed-url 'file:///workspace' \
|
||||||
--vmsize '${{ env.AZURE_VM_SIZE }}' \
|
--conn-details-output '/workspace' \
|
||||||
--tags project=podman-desktop \
|
--windows-version '${{ matrix.windows-version }}' \
|
||||||
--spot-excluded-regions ${{ env.MAPT_EXCLUDED_REGIONS }} \
|
--windows-featurepack '${{ matrix.windows-featurepack }}' \
|
||||||
--spot
|
--cpus ${{ env.MAPT_CPUS }} \
|
||||||
# Check logs
|
--memory ${{env.MAPT_MEMORY}} \
|
||||||
podman logs -f windows-create
|
--nested-virt \
|
||||||
|
--tags project=podman-desktop \
|
||||||
|
--spot-excluded-regions ${{ env.MAPT_EXCLUDED_REGIONS }} \
|
||||||
|
--spot
|
||||||
|
# Check logs
|
||||||
|
podman logs -f windows-create
|
||||||
|
else
|
||||||
|
echo "MAPT_VM_SIZE is set to '${{ env.MAPT_VM_SIZE }}', using size approach"
|
||||||
|
# Create instance with VM size
|
||||||
|
podman run -d --name windows-create --rm \
|
||||||
|
-v ${PWD}:/workspace:z \
|
||||||
|
-e ARM_TENANT_ID=${{ secrets.ARM_TENANT_ID }} \
|
||||||
|
-e ARM_SUBSCRIPTION_ID=${{ secrets.ARM_SUBSCRIPTION_ID }} \
|
||||||
|
-e ARM_CLIENT_ID=${{ secrets.ARM_CLIENT_ID }} \
|
||||||
|
-e ARM_CLIENT_SECRET='${{ secrets.ARM_CLIENT_SECRET }}' \
|
||||||
|
--user 0 \
|
||||||
|
${{ env.MAPT_IMAGE }}:${{ env.MAPT_VERSION_TAG }} azure \
|
||||||
|
windows create \
|
||||||
|
--project-name 'windows-desktop' \
|
||||||
|
--backed-url 'file:///workspace' \
|
||||||
|
--conn-details-output '/workspace' \
|
||||||
|
--windows-version '${{ matrix.windows-version }}' \
|
||||||
|
--windows-featurepack '${{ matrix.windows-featurepack }}' \
|
||||||
|
--vmsize '${{ env.MAPT_VM_SIZE }}' \
|
||||||
|
--tags project=podman-desktop \
|
||||||
|
--spot-excluded-regions ${{ env.MAPT_EXCLUDED_REGIONS }} \
|
||||||
|
--spot
|
||||||
|
# Check logs
|
||||||
|
podman logs -f windows-create
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Check instance system info
|
- name: Check instance system info
|
||||||
run: |
|
run: |
|
||||||
|
@ -221,8 +249,8 @@ jobs:
|
||||||
pd-e2e/builder.ps1 \
|
pd-e2e/builder.ps1 \
|
||||||
-targetFolder pd-e2e \
|
-targetFolder pd-e2e \
|
||||||
-resultsFolder results \
|
-resultsFolder results \
|
||||||
-fork ${{ env.FORK }} \
|
-fork ${{ env.PD_FORK }} \
|
||||||
-branch ${{ env.BRANCH }} \
|
-branch ${{ env.PD_BRANCH }} \
|
||||||
-envVars ${{ env.ENV_VARS }}
|
-envVars ${{ env.ENV_VARS }}
|
||||||
# check logs
|
# check logs
|
||||||
podman logs -f pde2e-builder-run
|
podman logs -f pde2e-builder-run
|
||||||
|
@ -244,8 +272,8 @@ jobs:
|
||||||
-resultsFolder results \
|
-resultsFolder results \
|
||||||
-podmanPath $(cat results/podman-location.log) \
|
-podmanPath $(cat results/podman-location.log) \
|
||||||
-pdPath "$(cat results/pde2e-binary-path.log | tr '\n' " ")" \
|
-pdPath "$(cat results/pde2e-binary-path.log | tr '\n' " ")" \
|
||||||
-fork ${{ env.FORK }} \
|
-fork ${{ env.PD_FORK }} \
|
||||||
-branch ${{ env.BRANCH }} \
|
-branch ${{ env.PD_BRANCH }} \
|
||||||
-extRepo ${{ env.EXT_REPO }} \
|
-extRepo ${{ env.EXT_REPO }} \
|
||||||
-extFork ${{ env.EXT_FORK }} \
|
-extFork ${{ env.EXT_FORK }} \
|
||||||
-extBranch ${{ env.EXT_BRANCH }} \
|
-extBranch ${{ env.EXT_BRANCH }} \
|
||||||
|
@ -270,7 +298,8 @@ jobs:
|
||||||
-e ARM_SUBSCRIPTION_ID=${{ secrets.ARM_SUBSCRIPTION_ID }} \
|
-e ARM_SUBSCRIPTION_ID=${{ secrets.ARM_SUBSCRIPTION_ID }} \
|
||||||
-e ARM_CLIENT_ID=${{ secrets.ARM_CLIENT_ID }} \
|
-e ARM_CLIENT_ID=${{ secrets.ARM_CLIENT_ID }} \
|
||||||
-e ARM_CLIENT_SECRET='${{ secrets.ARM_CLIENT_SECRET }}' \
|
-e ARM_CLIENT_SECRET='${{ secrets.ARM_CLIENT_SECRET }}' \
|
||||||
${{ env.MAPT_IMAGE }}:${{ env.MAPT_VERSION }} azure \
|
--user 0 \
|
||||||
|
${{ env.MAPT_IMAGE }}:${{ env.MAPT_VERSION_TAG }} azure \
|
||||||
windows destroy \
|
windows destroy \
|
||||||
--project-name 'windows-desktop' \
|
--project-name 'windows-desktop' \
|
||||||
--backed-url 'file:///workspace'
|
--backed-url 'file:///workspace'
|
||||||
|
|
|
@ -26,7 +26,7 @@ jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
|
|
|
@ -15,7 +15,7 @@ jobs:
|
||||||
|
|
||||||
# Steps represent a sequence of tasks that will be executed as part of the job
|
# Steps represent a sequence of tasks that will be executed as part of the job
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
# Runs a single command using the runners shell
|
# Runs a single command using the runners shell
|
||||||
- name: Compute model size
|
- name: Compute model size
|
||||||
run: ./tools/compute-model-sizes.sh
|
run: ./tools/compute-model-sizes.sh
|
||||||
|
|
|
@ -46,20 +46,20 @@ jobs:
|
||||||
name: Run E2E tests ${{ github.event_name == 'schedule' && '[nightly]' || '' }}
|
name: Run E2E tests ${{ github.event_name == 'schedule' && '[nightly]' || '' }}
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
if: github.event_name == 'workflow_dispatch'
|
if: github.event_name == 'workflow_dispatch'
|
||||||
with:
|
with:
|
||||||
repository: ${{ github.event.inputs.organization }}/${{ github.event.inputs.repositoryName }}
|
repository: ${{ github.event.inputs.organization }}/${{ github.event.inputs.repositoryName }}
|
||||||
ref: ${{ github.event.inputs.branch }}
|
ref: ${{ github.event.inputs.branch }}
|
||||||
path: ${{ github.event.inputs.repositoryName }}
|
path: ${{ github.event.inputs.repositoryName }}
|
||||||
|
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
if: github.event_name == 'push' || github.event_name == 'schedule'
|
if: github.event_name == 'push' || github.event_name == 'schedule'
|
||||||
with:
|
with:
|
||||||
path: podman-desktop-extension-ai-lab
|
path: podman-desktop-extension-ai-lab
|
||||||
|
|
||||||
# Checkout podman desktop
|
# Checkout podman desktop
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
repository: containers/podman-desktop
|
repository: containers/podman-desktop
|
||||||
ref: main
|
ref: main
|
||||||
|
@ -81,15 +81,18 @@ jobs:
|
||||||
|
|
||||||
- name: Update podman
|
- name: Update podman
|
||||||
run: |
|
run: |
|
||||||
# ubuntu version from kubic repository to install podman we need (v5)
|
echo "ubuntu version from kubic repository to install podman we need (v5)"
|
||||||
ubuntu_version='23.04'
|
ubuntu_version='23.10'
|
||||||
|
echo "Add unstable kubic repo into list of available sources and get the repo key"
|
||||||
sudo sh -c "echo 'deb https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/unstable/xUbuntu_${ubuntu_version}/ /' > /etc/apt/sources.list.d/devel:kubic:libcontainers:unstable.list"
|
sudo sh -c "echo 'deb https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/unstable/xUbuntu_${ubuntu_version}/ /' > /etc/apt/sources.list.d/devel:kubic:libcontainers:unstable.list"
|
||||||
curl -L "https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/unstable/xUbuntu_${ubuntu_version}/Release.key" | sudo apt-key add -
|
curl -L "https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/unstable/xUbuntu_${ubuntu_version}/Release.key" | sudo apt-key add -
|
||||||
# install necessary dependencies for criu package which is not part of 23.04
|
echo "Updating database of packages..."
|
||||||
sudo apt-get install -qq libprotobuf32t64 python3-protobuf libnet1
|
|
||||||
# install criu manually from static location
|
|
||||||
curl -sLO http://cz.archive.ubuntu.com/ubuntu/pool/universe/c/criu/criu_3.16.1-2_amd64.deb && sudo dpkg -i criu_3.16.1-2_amd64.deb
|
|
||||||
sudo apt-get update -qq
|
sudo apt-get update -qq
|
||||||
|
echo "install necessary dependencies for criu package which is not part of ${ubuntu_version}"
|
||||||
|
sudo apt-get install -qq libprotobuf32t64 python3-protobuf libnet1
|
||||||
|
echo "install criu manually from static location"
|
||||||
|
curl -sLO http://archive.ubuntu.com/ubuntu/pool/universe/c/criu/criu_3.16.1-2_amd64.deb && sudo dpkg -i criu_3.16.1-2_amd64.deb
|
||||||
|
echo "installing/update podman package..."
|
||||||
sudo apt-get -qq -y install podman || { echo "Start fallback steps for podman nightly installation from a static mirror" && \
|
sudo apt-get -qq -y install podman || { echo "Start fallback steps for podman nightly installation from a static mirror" && \
|
||||||
sudo sh -c "echo 'deb http://ftp.lysator.liu.se/pub/opensuse/repositories/devel:/kubic:/libcontainers:/unstable/xUbuntu_${ubuntu_version}/ /' > /etc/apt/sources.list.d/devel:kubic:libcontainers:unstable.list" && \
|
sudo sh -c "echo 'deb http://ftp.lysator.liu.se/pub/opensuse/repositories/devel:/kubic:/libcontainers:/unstable/xUbuntu_${ubuntu_version}/ /' > /etc/apt/sources.list.d/devel:kubic:libcontainers:unstable.list" && \
|
||||||
curl -L "http://ftp.lysator.liu.se/pub/opensuse/repositories/devel:/kubic:/libcontainers:/unstable/xUbuntu_${ubuntu_version}/Release.key" | sudo apt-key add - && \
|
curl -L "http://ftp.lysator.liu.se/pub/opensuse/repositories/devel:/kubic:/libcontainers:/unstable/xUbuntu_${ubuntu_version}/Release.key" | sudo apt-key add - && \
|
||||||
|
|
|
@ -0,0 +1,53 @@
|
||||||
|
#
|
||||||
|
# Copyright (C) 2025 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: llama-stack-playground
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: 'llama-stack tag to use (e.g. main, v0.2.8,...)'
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
name: publish
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
||||||
|
with:
|
||||||
|
repository: meta-llama/llama-stack
|
||||||
|
ref: ${{ github.event.inputs.version }}
|
||||||
|
|
||||||
|
- name: Install qemu dependency
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y qemu-user-static
|
||||||
|
|
||||||
|
- name: Build manifest and images
|
||||||
|
run: |
|
||||||
|
podman manifest create quay.io/podman-ai-lab/llama-stack-playground:${{ github.event.inputs.version }}
|
||||||
|
podman build --platform linux/amd64,linux/arm64 llama_stack/distribution/ui --manifest quay.io/podman-ai-lab/llama-stack-playground:${{ github.event.inputs.version }}
|
||||||
|
|
||||||
|
- name: Login to quay.io
|
||||||
|
run: podman login quay.io --username ${{ secrets.QUAY_USERNAME }} --password ${{ secrets.QUAY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Push manifest and images to quay.io
|
||||||
|
run: podman manifest push quay.io/podman-ai-lab/llama-stack-playground:${{ github.event.inputs.version }}
|
||||||
|
|
|
@ -29,7 +29,7 @@ jobs:
|
||||||
matrix:
|
matrix:
|
||||||
os: [windows-2022, ubuntu-22.04, macos-14]
|
os: [windows-2022, ubuntu-22.04, macos-14]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
|
@ -74,7 +74,7 @@ jobs:
|
||||||
env:
|
env:
|
||||||
SKIP_INSTALLATION: true
|
SKIP_INSTALLATION: true
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
path: podman-desktop-extension-ai-lab
|
path: podman-desktop-extension-ai-lab
|
||||||
# Set up pnpm
|
# Set up pnpm
|
||||||
|
@ -88,7 +88,7 @@ jobs:
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
# Checkout podman desktop
|
# Checkout podman desktop
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
repository: containers/podman-desktop
|
repository: containers/podman-desktop
|
||||||
ref: main
|
ref: main
|
||||||
|
@ -96,15 +96,18 @@ jobs:
|
||||||
|
|
||||||
- name: Update podman
|
- name: Update podman
|
||||||
run: |
|
run: |
|
||||||
# ubuntu version from kubic repository to install podman we need (v5)
|
echo "ubuntu version from kubic repository to install podman we need (v5)"
|
||||||
ubuntu_version='23.04'
|
ubuntu_version='23.10'
|
||||||
|
echo "Add unstable kubic repo into list of available sources and get the repo key"
|
||||||
sudo sh -c "echo 'deb https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/unstable/xUbuntu_${ubuntu_version}/ /' > /etc/apt/sources.list.d/devel:kubic:libcontainers:unstable.list"
|
sudo sh -c "echo 'deb https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/unstable/xUbuntu_${ubuntu_version}/ /' > /etc/apt/sources.list.d/devel:kubic:libcontainers:unstable.list"
|
||||||
curl -L "https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/unstable/xUbuntu_${ubuntu_version}/Release.key" | sudo apt-key add -
|
curl -L "https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/unstable/xUbuntu_${ubuntu_version}/Release.key" | sudo apt-key add -
|
||||||
# install necessary dependencies for criu package which is not part of 23.04
|
echo "Updating database of packages..."
|
||||||
sudo apt-get install -qq libprotobuf32t64 python3-protobuf libnet1
|
|
||||||
# install criu manually from static location
|
|
||||||
curl -sLO http://cz.archive.ubuntu.com/ubuntu/pool/universe/c/criu/criu_3.16.1-2_amd64.deb && sudo dpkg -i criu_3.16.1-2_amd64.deb
|
|
||||||
sudo apt-get update -qq
|
sudo apt-get update -qq
|
||||||
|
echo "install necessary dependencies for criu package which is not part of ${ubuntu_version}"
|
||||||
|
sudo apt-get install -qq libprotobuf32t64 python3-protobuf libnet1
|
||||||
|
echo "install criu manually from static location"
|
||||||
|
curl -sLO http://archive.ubuntu.com/ubuntu/pool/universe/c/criu/criu_3.16.1-2_amd64.deb && sudo dpkg -i criu_3.16.1-2_amd64.deb
|
||||||
|
echo "installing/update podman package..."
|
||||||
sudo apt-get -qq -y install podman || { echo "Start fallback steps for podman nightly installation from a static mirror" && \
|
sudo apt-get -qq -y install podman || { echo "Start fallback steps for podman nightly installation from a static mirror" && \
|
||||||
sudo sh -c "echo 'deb http://ftp.lysator.liu.se/pub/opensuse/repositories/devel:/kubic:/libcontainers:/unstable/xUbuntu_${ubuntu_version}/ /' > /etc/apt/sources.list.d/devel:kubic:libcontainers:unstable.list" && \
|
sudo sh -c "echo 'deb http://ftp.lysator.liu.se/pub/opensuse/repositories/devel:/kubic:/libcontainers:/unstable/xUbuntu_${ubuntu_version}/ /' > /etc/apt/sources.list.d/devel:kubic:libcontainers:unstable.list" && \
|
||||||
curl -L "http://ftp.lysator.liu.se/pub/opensuse/repositories/devel:/kubic:/libcontainers:/unstable/xUbuntu_${ubuntu_version}/Release.key" | sudo apt-key add - && \
|
curl -L "http://ftp.lysator.liu.se/pub/opensuse/repositories/devel:/kubic:/libcontainers:/unstable/xUbuntu_${ubuntu_version}/Release.key" | sudo apt-key add - && \
|
||||||
|
|
|
@ -36,7 +36,7 @@ jobs:
|
||||||
env:
|
env:
|
||||||
SKIP_INSTALLATION: true
|
SKIP_INSTALLATION: true
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
path: podman-desktop-extension-ai-lab
|
path: podman-desktop-extension-ai-lab
|
||||||
# Set up pnpm
|
# Set up pnpm
|
||||||
|
@ -50,7 +50,7 @@ jobs:
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
# Checkout podman desktop
|
# Checkout podman desktop
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
repository: podman-desktop/podman-desktop
|
repository: podman-desktop/podman-desktop
|
||||||
ref: main
|
ref: main
|
||||||
|
@ -58,15 +58,18 @@ jobs:
|
||||||
|
|
||||||
- name: Update podman
|
- name: Update podman
|
||||||
run: |
|
run: |
|
||||||
# ubuntu version from kubic repository to install podman we need (v5)
|
echo "ubuntu version from kubic repository to install podman we need (v5)"
|
||||||
ubuntu_version='23.04'
|
ubuntu_version='23.10'
|
||||||
|
echo "Add unstable kubic repo into list of available sources and get the repo key"
|
||||||
sudo sh -c "echo 'deb https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/unstable/xUbuntu_${ubuntu_version}/ /' > /etc/apt/sources.list.d/devel:kubic:libcontainers:unstable.list"
|
sudo sh -c "echo 'deb https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/unstable/xUbuntu_${ubuntu_version}/ /' > /etc/apt/sources.list.d/devel:kubic:libcontainers:unstable.list"
|
||||||
curl -L "https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/unstable/xUbuntu_${ubuntu_version}/Release.key" | sudo apt-key add -
|
curl -L "https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/unstable/xUbuntu_${ubuntu_version}/Release.key" | sudo apt-key add -
|
||||||
# install necessary dependencies for criu package which is not part of 23.04
|
echo "Updating database of packages..."
|
||||||
sudo apt-get install -qq libprotobuf32t64 python3-protobuf libnet1
|
|
||||||
# install criu manually from static location
|
|
||||||
curl -sLO http://cz.archive.ubuntu.com/ubuntu/pool/universe/c/criu/criu_3.16.1-2_amd64.deb && sudo dpkg -i criu_3.16.1-2_amd64.deb
|
|
||||||
sudo apt-get update -qq
|
sudo apt-get update -qq
|
||||||
|
echo "install necessary dependencies for criu package which is not part of ${ubuntu_version}"
|
||||||
|
sudo apt-get install -qq libprotobuf32t64 python3-protobuf libnet1
|
||||||
|
echo "install criu manually from static location"
|
||||||
|
curl -sLO http://archive.ubuntu.com/ubuntu/pool/universe/c/criu/criu_3.16.1-2_amd64.deb && sudo dpkg -i criu_3.16.1-2_amd64.deb
|
||||||
|
echo "installing/update podman package..."
|
||||||
sudo apt-get -qq -y install podman || { echo "Start fallback steps for podman nightly installation from a static mirror" && \
|
sudo apt-get -qq -y install podman || { echo "Start fallback steps for podman nightly installation from a static mirror" && \
|
||||||
sudo sh -c "echo 'deb http://ftp.lysator.liu.se/pub/opensuse/repositories/devel:/kubic:/libcontainers:/unstable/xUbuntu_${ubuntu_version}/ /' > /etc/apt/sources.list.d/devel:kubic:libcontainers:unstable.list" && \
|
sudo sh -c "echo 'deb http://ftp.lysator.liu.se/pub/opensuse/repositories/devel:/kubic:/libcontainers:/unstable/xUbuntu_${ubuntu_version}/ /' > /etc/apt/sources.list.d/devel:kubic:libcontainers:unstable.list" && \
|
||||||
curl -L "http://ftp.lysator.liu.se/pub/opensuse/repositories/devel:/kubic:/libcontainers:/unstable/xUbuntu_${ubuntu_version}/Release.key" | sudo apt-key add - && \
|
curl -L "http://ftp.lysator.liu.se/pub/opensuse/repositories/devel:/kubic:/libcontainers:/unstable/xUbuntu_${ubuntu_version}/Release.key" | sudo apt-key add - && \
|
||||||
|
@ -108,7 +111,7 @@ jobs:
|
||||||
|
|
||||||
- name: Update ramalama image references in AI Lab Extension
|
- name: Update ramalama image references in AI Lab Extension
|
||||||
working-directory: ./podman-desktop-extension-ai-lab
|
working-directory: ./podman-desktop-extension-ai-lab
|
||||||
run: sed -i -E "s/(@sha256:[0-9a-f]+)/:${{ github.event.inputs.tag }}/g" packages/backend/src/assets/inference-images.json
|
run: sed -i -E "s/(@sha256:[0-9a-f]+)/:${{ github.event_name != 'workflow_dispatch' && 'latest' || github.event.inputs.tag }}/g" packages/backend/src/assets/inference-images.json
|
||||||
|
|
||||||
- name: Build Image
|
- name: Build Image
|
||||||
working-directory: ./podman-desktop-extension-ai-lab
|
working-directory: ./podman-desktop-extension-ai-lab
|
||||||
|
|
|
@ -0,0 +1,49 @@
|
||||||
|
name: recipe-catalog-change-cleanup
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["recipe-catalog-change-windows-trigger"]
|
||||||
|
types:
|
||||||
|
- completed
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
extract-context:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
outputs:
|
||||||
|
extract-context: ${{ steps.prepare-context.outputs.extract-context }}
|
||||||
|
trigger-template: ${{ steps.prepare-context.outputs.trigger-template }}
|
||||||
|
steps:
|
||||||
|
- name: Prepare context
|
||||||
|
id: prepare-context
|
||||||
|
env:
|
||||||
|
WORKFLOW_RUN: ${{ toJson(github.event.workflow_run) }}
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
echo "Workflow run ID: ${{ fromJson(env.WORKFLOW_RUN).id }}"
|
||||||
|
echo "Fork owner: ${{ fromJson(env.WORKFLOW_RUN).head_repository.owner.login }}"
|
||||||
|
echo "Fork repo: ${{ fromJson(env.WORKFLOW_RUN).head_repository.name }}"
|
||||||
|
echo "Fork branch: ${{ fromJson(env.WORKFLOW_RUN).head_branch }}"
|
||||||
|
echo "Commit SHA: ${{ fromJson(env.WORKFLOW_RUN).head_sha }}"
|
||||||
|
echo "Base repo: ${{ fromJson(env.WORKFLOW_RUN).repository.full_name }}"
|
||||||
|
echo "Conclusion: ${{ fromJson(env.WORKFLOW_RUN).conclusion }}"
|
||||||
|
# Fetch job conclusions using the GitHub CLI
|
||||||
|
echo "Fetching jobs for workflow run ID: ${{ fromJson(env.WORKFLOW_RUN).id }}"
|
||||||
|
gh api \
|
||||||
|
repos/${{ github.repository }}/actions/runs/${{ fromJson(env.WORKFLOW_RUN).id }}/jobs \
|
||||||
|
--jq '.jobs[] | "\(.name)=\(.conclusion)"' | while read -r line; do
|
||||||
|
echo "$line" >> $GITHUB_OUTPUT
|
||||||
|
done
|
||||||
|
cat $GITHUB_OUTPUT
|
||||||
|
cleanup:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
needs: extract-context
|
||||||
|
if: ${{ github.event.workflow_run.conclusion == 'skipped' || (github.event.workflow_run.conclusion == 'success' && needs.extract-context.outputs.trigger-template == 'skipped') }}
|
||||||
|
steps:
|
||||||
|
- name: Remove skipped or cancelled workflow run
|
||||||
|
env:
|
||||||
|
WORKFLOW_RUN: ${{ toJson(github.event.workflow_run) }}
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
echo "Cleaning up workflow run ID: ${{ fromJson(env.WORKFLOW_RUN).id }}"
|
||||||
|
gh run delete ${{ fromJson(env.WORKFLOW_RUN).id }} --repo ${{ fromJson(env.WORKFLOW_RUN).repository.full_name }}
|
||||||
|
echo "Workflow run ID ${{ fromJson(env.WORKFLOW_RUN).id }} has been cleaned up."
|
|
@ -45,7 +45,7 @@ on:
|
||||||
pde2e-image-version:
|
pde2e-image-version:
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
azure-vm-size:
|
mapt_params:
|
||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
|
@ -53,10 +53,6 @@ jobs:
|
||||||
windows:
|
windows:
|
||||||
name: recipe-catalog-windows-${{ matrix.windows-version }}-${{ matrix.windows-featurepack }}
|
name: recipe-catalog-windows-${{ matrix.windows-version }}-${{ matrix.windows-featurepack }}
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
env:
|
|
||||||
MAPT_VERSION: v0.7.4
|
|
||||||
MAPT_IMAGE: quay.io/redhat-developer/mapt
|
|
||||||
MAPT_EXCLUDED_REGIONS: 'westindia,centralindia,southindia,australiacentral,australiacentral2,australiaeast,australiasoutheast,southafricanorth,southafricawest'
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
|
@ -68,14 +64,14 @@ jobs:
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
status_context="ci/gh/e2e/windows-matrix-${{ matrix.windows-version }}-${{ matrix.windows-featurepack }}"
|
status_context="catalog-change-windows-matrix-${{ matrix.windows-version }}-${{ matrix.windows-featurepack }}"
|
||||||
echo "status_context=${status_context}" >> "$GITHUB_ENV"
|
echo "status_context=${status_context}" >> "$GITHUB_ENV"
|
||||||
set -xuo
|
set -xuo
|
||||||
# Status msg
|
# Status msg
|
||||||
data="{\"state\":\"pending\""
|
data="{\"state\":\"pending\""
|
||||||
data="${data},\"description\":\"Running recipe tests on catalog change on Windows ${{ matrix.windows-version }}-${{ matrix.windows-featurepack }}\""
|
data="${data},\"description\":\"Running recipe tests on catalog change on Windows ${{ matrix.windows-version }}-${{ matrix.windows-featurepack }}\""
|
||||||
data="${data},\"context\":\"$status_context\""
|
data="${data},\"context\":\"$status_context\""
|
||||||
data="${data},\"target_url\":\"https://github.com/${{ inputs.trigger-workflow-base-repo }}/actions/runs/${{ inputs.trigger-workflow-run-id }}\"}"
|
data="${data},\"target_url\":\"https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}\"}"
|
||||||
# Create status by API call
|
# Create status by API call
|
||||||
curl -L -v -X POST \
|
curl -L -v -X POST \
|
||||||
-H "Accept: application/vnd.github+json" \
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
@ -96,12 +92,12 @@ jobs:
|
||||||
DEFAULT_NPM_TARGET: 'test:e2e'
|
DEFAULT_NPM_TARGET: 'test:e2e'
|
||||||
DEFAULT_ENV_VARS: 'TEST_PODMAN_MACHINE=true,ELECTRON_ENABLE_INSPECT=true'
|
DEFAULT_ENV_VARS: 'TEST_PODMAN_MACHINE=true,ELECTRON_ENABLE_INSPECT=true'
|
||||||
DEFAULT_PODMAN_OPTIONS: 'INIT=1,START=1,ROOTFUL=1,NETWORKING=0'
|
DEFAULT_PODMAN_OPTIONS: 'INIT=1,START=1,ROOTFUL=1,NETWORKING=0'
|
||||||
DEFAULT_EXT_TESTS_OPTIONS: 'EXT_RUN_TESTS_FROM_EXTENSION=1,EXT_RUN_TESTS_AS_ADMIN=1'
|
DEFAULT_EXT_TESTS_OPTIONS: 'EXT_RUN_TESTS_FROM_EXTENSION=1,EXT_RUN_TESTS_AS_ADMIN=1,EXT_TEST_GPU_SUPPORT_ENABLED=0'
|
||||||
DEFAULT_EXT_REPO_OPTIONS: 'REPO=podman-desktop-extension-ai-lab,FORK=containers,BRANCH=main'
|
DEFAULT_EXT_REPO_OPTIONS: 'REPO=podman-desktop-extension-ai-lab,FORK=containers,BRANCH=main'
|
||||||
DEFAULT_PODMAN_VERSION: "${{ env.PD_PODMAN_VERSION || '5.3.2' }}"
|
DEFAULT_PODMAN_VERSION: "${{ env.PD_PODMAN_VERSION || '5.3.2' }}"
|
||||||
DEFAULT_URL: "https://github.com/containers/podman/releases/download/v$DEFAULT_PODMAN_VERSION/podman-$DEFAULT_PODMAN_VERSION-setup.exe"
|
DEFAULT_URL: "https://github.com/containers/podman/releases/download/v$DEFAULT_PODMAN_VERSION/podman-$DEFAULT_PODMAN_VERSION-setup.exe"
|
||||||
DEFAULT_PDE2E_IMAGE_VERSION: 'v0.0.3-windows'
|
DEFAULT_PDE2E_IMAGE_VERSION: 'v0.0.3-windows'
|
||||||
DEFAULT_AZURE_VM_SIZE: 'Standard_D8as_v5'
|
DEFAULT_MAPT_PARAMS: "IMAGE=${{ vars.MAPT_IMAGE || 'quay.io/redhat-developer/mapt' }},VERSION_TAG=${{ vars.MAPT_VERSION_TAG || 'v0.9.5' }},CPUS=${{ vars.MAPT_CPUS || '4' }},MEMORY=${{ vars.MAPT_MEMORY || '32' }},EXCLUDED_REGIONS=\"${{ vars.MAPT_EXCLUDED_REGIONS || 'westindia,centralindia,southindia,australiacentral,australiacentral2,australiaeast,australiasoutheast,southafricanorth,southafricawest' }}\""
|
||||||
run: |
|
run: |
|
||||||
echo "FORK=${{ inputs.pd-fork || env.DEFAULT_FORK }}" >> $GITHUB_ENV
|
echo "FORK=${{ inputs.pd-fork || env.DEFAULT_FORK }}" >> $GITHUB_ENV
|
||||||
echo "BRANCH=${{ inputs.pd-branch || env.DEFAULT_BRANCH }}" >> $GITHUB_ENV
|
echo "BRANCH=${{ inputs.pd-branch || env.DEFAULT_BRANCH }}" >> $GITHUB_ENV
|
||||||
|
@ -113,12 +109,13 @@ jobs:
|
||||||
echo "DEFAULT_EXT_REPO_OPTIONS=REPO=${{ inputs.trigger-workflow-repo-name }},FORK=${{ inputs.trigger-workflow-fork }},BRANCH=${{ inputs.trigger-workflow-branch }}" >> $GITHUB_ENV
|
echo "DEFAULT_EXT_REPO_OPTIONS=REPO=${{ inputs.trigger-workflow-repo-name }},FORK=${{ inputs.trigger-workflow-fork }},BRANCH=${{ inputs.trigger-workflow-branch }}" >> $GITHUB_ENV
|
||||||
fi
|
fi
|
||||||
echo "${{ github.event.inputs.ext_tests_options || env.DEFAULT_EXT_TESTS_OPTIONS }}" | awk -F ',' \
|
echo "${{ github.event.inputs.ext_tests_options || env.DEFAULT_EXT_TESTS_OPTIONS }}" | awk -F ',' \
|
||||||
'{for (i=1; i<=NF; i++) {split($i, kv, "="); print kv[1]"="kv[2]}}' >> $GITHUB_ENV
|
'{for (i=1; i<=NF; i++) {split($i, kv, "="); print kv[1]"="kv[2]}}' >> $GITHUB_ENV
|
||||||
echo "${{ env.DEFAULT_PODMAN_OPTIONS }}" | awk -F ',' \
|
echo "${{ env.DEFAULT_PODMAN_OPTIONS }}" | awk -F ',' \
|
||||||
'{for (i=1; i<=NF; i++) {split($i, kv, "="); print "PODMAN_"kv[1]"="kv[2]}}' >> $GITHUB_ENV
|
'{for (i=1; i<=NF; i++) {split($i, kv, "="); print "PODMAN_"kv[1]"="kv[2]}}' >> $GITHUB_ENV
|
||||||
echo "${{ inputs.podman-options || env.DEFAULT_EXT_REPO_OPTIONS }}" | awk -F ',' \
|
echo "${{ inputs.podman-options || env.DEFAULT_EXT_REPO_OPTIONS }}" | awk -F ',' \
|
||||||
'{for (i=1; i<=NF; i++) {split($i, kv, "="); print "EXT_"kv[1]"="kv[2]}}' >> $GITHUB_ENV
|
'{for (i=1; i<=NF; i++) {split($i, kv, "="); print "EXT_"kv[1]"="kv[2]}}' >> $GITHUB_ENV
|
||||||
echo "AZURE_VM_SIZE=${{ inputs.azure-vm-size || env.DEFAULT_AZURE_VM_SIZE }}" >> $GITHUB_ENV
|
echo "${{ github.event.inputs.mapt_params || env.DEFAULT_MAPT_PARAMS }}" | awk -F ',' \
|
||||||
|
'{for (i=1; i<=NF; i++) {split($i, kv, "="); print "MAPT_"kv[1]"="kv[2]}}' >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Create instance
|
- name: Create instance
|
||||||
run: |
|
run: |
|
||||||
|
@ -129,14 +126,17 @@ jobs:
|
||||||
-e ARM_SUBSCRIPTION_ID=${{ secrets.ARM_SUBSCRIPTION_ID }} \
|
-e ARM_SUBSCRIPTION_ID=${{ secrets.ARM_SUBSCRIPTION_ID }} \
|
||||||
-e ARM_CLIENT_ID=${{ secrets.ARM_CLIENT_ID }} \
|
-e ARM_CLIENT_ID=${{ secrets.ARM_CLIENT_ID }} \
|
||||||
-e ARM_CLIENT_SECRET='${{ secrets.ARM_CLIENT_SECRET }}' \
|
-e ARM_CLIENT_SECRET='${{ secrets.ARM_CLIENT_SECRET }}' \
|
||||||
${{ env.MAPT_IMAGE }}:${{ env.MAPT_VERSION }} azure \
|
--user 0 \
|
||||||
|
${{ env.MAPT_IMAGE }}:${{ env.MAPT_VERSION_TAG }} azure \
|
||||||
windows create \
|
windows create \
|
||||||
--project-name 'windows-desktop' \
|
--project-name 'windows-desktop' \
|
||||||
--backed-url 'file:///workspace' \
|
--backed-url 'file:///workspace' \
|
||||||
--conn-details-output '/workspace' \
|
--conn-details-output '/workspace' \
|
||||||
--windows-version '${{ matrix.windows-version }}' \
|
--windows-version '${{ matrix.windows-version }}' \
|
||||||
--windows-featurepack '${{ matrix.windows-featurepack }}' \
|
--windows-featurepack '${{ matrix.windows-featurepack }}' \
|
||||||
--vmsize '${{ env.AZURE_VM_SIZE }}' \
|
--cpus ${{ env.MAPT_CPUS }} \
|
||||||
|
--memory ${{ env.MAPT_MEMORY }} \
|
||||||
|
--nested-virt \
|
||||||
--tags project=podman-desktop \
|
--tags project=podman-desktop \
|
||||||
--spot-excluded-regions ${{ env.MAPT_EXCLUDED_REGIONS }} \
|
--spot-excluded-regions ${{ env.MAPT_EXCLUDED_REGIONS }} \
|
||||||
--spot
|
--spot
|
||||||
|
@ -268,8 +268,8 @@ jobs:
|
||||||
data="{\"state\":\"failure\""
|
data="{\"state\":\"failure\""
|
||||||
fi
|
fi
|
||||||
data="${data},\"description\":\"Finished recipe tests on catalog change on Windows ${{ matrix.windows-version }}-${{ matrix.windows-featurepack }}\""
|
data="${data},\"description\":\"Finished recipe tests on catalog change on Windows ${{ matrix.windows-version }}-${{ matrix.windows-featurepack }}\""
|
||||||
data="${data},\"context\":\"$status_context\""
|
data="${data},\"context\":\"${{ env.status_context }}\""
|
||||||
data="${data},\"target_url\":\"https://github.com/${{ inputs.trigger-workflow-base-repo }}/actions/runs/${{ inputs.trigger-workflow-run-id }}\"}"
|
data="${data},\"target_url\":\"https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}\"}"
|
||||||
# Create status by API call
|
# Create status by API call
|
||||||
curl -L -v -X POST \
|
curl -L -v -X POST \
|
||||||
-H "Accept: application/vnd.github+json" \
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
@ -287,7 +287,8 @@ jobs:
|
||||||
-e ARM_SUBSCRIPTION_ID=${{ secrets.ARM_SUBSCRIPTION_ID }} \
|
-e ARM_SUBSCRIPTION_ID=${{ secrets.ARM_SUBSCRIPTION_ID }} \
|
||||||
-e ARM_CLIENT_ID=${{ secrets.ARM_CLIENT_ID }} \
|
-e ARM_CLIENT_ID=${{ secrets.ARM_CLIENT_ID }} \
|
||||||
-e ARM_CLIENT_SECRET='${{ secrets.ARM_CLIENT_SECRET }}' \
|
-e ARM_CLIENT_SECRET='${{ secrets.ARM_CLIENT_SECRET }}' \
|
||||||
${{ env.MAPT_IMAGE }}:${{ env.MAPT_VERSION }} azure \
|
--user 0 \
|
||||||
|
${{ env.MAPT_IMAGE }}:${{ env.MAPT_VERSION_TAG }} azure \
|
||||||
windows destroy \
|
windows destroy \
|
||||||
--project-name 'windows-desktop' \
|
--project-name 'windows-desktop' \
|
||||||
--backed-url 'file:///workspace'
|
--backed-url 'file:///workspace'
|
||||||
|
|
|
@ -49,7 +49,6 @@ jobs:
|
||||||
else
|
else
|
||||||
echo "No changes detected in ai.json"
|
echo "No changes detected in ai.json"
|
||||||
echo "changes-detected=false" >> $GITHUB_OUTPUT
|
echo "changes-detected=false" >> $GITHUB_OUTPUT
|
||||||
gh run cancel ${{ github.run_id}}
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
trigger-template:
|
trigger-template:
|
||||||
|
@ -65,5 +64,5 @@ jobs:
|
||||||
trigger-workflow-branch: ${{ needs.extract-context.outputs.fork-branch }}
|
trigger-workflow-branch: ${{ needs.extract-context.outputs.fork-branch }}
|
||||||
trigger-workflow-commit-sha: ${{ needs.extract-context.outputs.commit-sha }}
|
trigger-workflow-commit-sha: ${{ needs.extract-context.outputs.commit-sha }}
|
||||||
trigger-workflow-base-repo: ${{ needs.extract-context.outputs.base-repo }}
|
trigger-workflow-base-repo: ${{ needs.extract-context.outputs.base-repo }}
|
||||||
ext_tests_options: 'EXT_RUN_TESTS_FROM_EXTENSION=1,EXT_RUN_TESTS_AS_ADMIN=0'
|
ext_tests_options: 'EXT_RUN_TESTS_FROM_EXTENSION=1,EXT_RUN_TESTS_AS_ADMIN=0,EXT_TEST_GPU_SUPPORT_ENABLED=0'
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
|
@ -41,7 +41,7 @@ jobs:
|
||||||
releaseId: ${{ steps.create_release.outputs.id}}
|
releaseId: ${{ steps.create_release.outputs.id}}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.inputs.branch }}
|
ref: ${{ github.event.inputs.branch }}
|
||||||
- name: Generate tag utilities
|
- name: Generate tag utilities
|
||||||
|
@ -116,7 +116,7 @@ jobs:
|
||||||
needs: [tag]
|
needs: [tag]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
ref: ${{ needs.tag.outputs.githubTag }}
|
ref: ${{ needs.tag.outputs.githubTag }}
|
||||||
|
|
||||||
|
@ -150,7 +150,7 @@ jobs:
|
||||||
release:
|
release:
|
||||||
needs: [tag, build]
|
needs: [tag, build]
|
||||||
name: Release
|
name: Release
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: id
|
- name: id
|
||||||
run: echo the release id is ${{ needs.tag.outputs.releaseId}}
|
run: echo the release id is ${{ needs.tag.outputs.releaseId}}
|
||||||
|
|
|
@ -0,0 +1,54 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Copyright (C) 2025 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
|
||||||
|
|
||||||
|
# Script to update ramalama image references in inference-images.json
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
JSON_PATH="packages/backend/src/assets/inference-images.json"
|
||||||
|
TMP_JSON="${JSON_PATH}.tmp"
|
||||||
|
|
||||||
|
TAG=$1
|
||||||
|
# Images and their keys in the JSON
|
||||||
|
IMAGES=(
|
||||||
|
"whispercpp:ramalama/ramalama-whisper-server:default"
|
||||||
|
"llamacpp:ramalama/ramalama-llama-server:default"
|
||||||
|
"llamacpp:ramalama/cuda-llama-server:cuda"
|
||||||
|
"openvino:ramalama/openvino:default"
|
||||||
|
)
|
||||||
|
|
||||||
|
cp "$JSON_PATH" "$TMP_JSON"
|
||||||
|
|
||||||
|
for entry in "${IMAGES[@]}"; do
|
||||||
|
IFS=":" read -r key image jsonkey <<< "$entry"
|
||||||
|
digest=$(curl -s "https://quay.io/v2/$image/manifests/$TAG" -H 'Accept: application/vnd.oci.image.index.v1+json' --head | grep -i Docker-Content-Digest | awk -e '{ print $2 }' | tr -d '\r')
|
||||||
|
# Update the JSON file with the new digest
|
||||||
|
jq --arg img "quay.io/$image" --arg dig "$digest" --arg key "$key" --arg jsonkey "$jsonkey" \
|
||||||
|
'(.[$key][$jsonkey]) = ($img + "@" + $dig)' \
|
||||||
|
"$TMP_JSON" > "$TMP_JSON.new" && mv "$TMP_JSON.new" "$TMP_JSON"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Compare and update if changed
|
||||||
|
if cmp -s "$JSON_PATH" "$TMP_JSON"; then
|
||||||
|
echo "No update needed: digests are up to date."
|
||||||
|
rm "$TMP_JSON"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
mv "$TMP_JSON" "$JSON_PATH"
|
||||||
|
echo "Updated inference-images.json with latest digests."
|
||||||
|
exit 10
|
||||||
|
fi
|
|
@ -0,0 +1,87 @@
|
||||||
|
#
|
||||||
|
# Copyright (C) 2025 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
|
||||||
|
|
||||||
|
# This workflow automatically updates ramalama image digests in inference-images.json
|
||||||
|
# and creates a pull request with the changes.
|
||||||
|
|
||||||
|
name: update-ramalama-references
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 3 * * *' # Runs daily at 03:00 UTC
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update-references:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
|
||||||
|
- name: Get latest ramalama version
|
||||||
|
id: get_ramalama_version
|
||||||
|
run: |
|
||||||
|
RAMALAMA_VERSION=$(curl -s https://quay.io/v2/ramalama/ramalama-llama-server/tags/list -s | jq .tags[] | grep -E '^"[0-9]+\.[0-9]+\.[0-9]+"$' | sort -V | tail -n 1 | tr -d '"')
|
||||||
|
echo "RAMALAMA_VERSION=${RAMALAMA_VERSION}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Check if PR already exists
|
||||||
|
id: pr_exists
|
||||||
|
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const branch = `update-ramalama-references-${{ steps.get_ramalama_version.outputs.RAMALAMA_VERSION }}`;
|
||||||
|
const { data: pulls } = await github.rest.pulls.list({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
head: `${context.repo.owner}:${branch}`,
|
||||||
|
state: 'open',
|
||||||
|
});
|
||||||
|
if (pulls.length > 0) {
|
||||||
|
core.setOutput('exists', 'true');
|
||||||
|
} else {
|
||||||
|
core.setOutput('exists', 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Update ramalama image references in inference-images.json
|
||||||
|
id: update_digests
|
||||||
|
if: steps.pr_exists.outputs.exists == 'false'
|
||||||
|
run: |
|
||||||
|
bash .github/workflows/update-ramalama-references.sh "${{ steps.get_ramalama_version.outputs.RAMALAMA_VERSION }}"
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Commit changes
|
||||||
|
if: steps.pr_exists.outputs.exists == 'false' && steps.update_digests.outcome == 'failure'
|
||||||
|
run: |
|
||||||
|
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git config --global user.name "github-actions[bot]"
|
||||||
|
git checkout -b "update-ramalama-references-${{ steps.get_ramalama_version.outputs.RAMALAMA_VERSION }}"
|
||||||
|
git add packages/backend/src/assets/inference-images.json
|
||||||
|
git commit -m "chore: update ramalama image references ${{ steps.get_ramalama_version.outputs.RAMALAMA_VERSION }}"
|
||||||
|
git push origin "update-ramalama-references-${{ steps.get_ramalama_version.outputs.RAMALAMA_VERSION }}"
|
||||||
|
|
||||||
|
- name: Create Pull Request
|
||||||
|
if: steps.pr_exists.outputs.exists == 'false' && steps.update_digests.outcome == 'failure'
|
||||||
|
run: |
|
||||||
|
echo -e "update ramalama image references to ${{ steps.get_ramalama_version.outputs.RAMALAMA_VERSION }}" > /tmp/pr-title
|
||||||
|
pullRequestUrl=$(gh pr create --title "chore: update ramalama image references to ${{ steps.get_ramalama_version.outputs.RAMALAMA_VERSION }}" --body-file /tmp/pr-title --head "update-ramalama-references-${{ steps.get_ramalama_version.outputs.RAMALAMA_VERSION }}" --base "main")
|
||||||
|
echo "📢 Pull request created: ${pullRequestUrl}"
|
||||||
|
echo "➡️ Flag the PR as being ready for review"
|
||||||
|
gh pr ready "${pullRequestUrl}"
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.PODMAN_DESKTOP_BOT_TOKEN }}
|
|
@ -21,6 +21,7 @@ COPY packages/backend/package.json /extension/
|
||||||
COPY packages/backend/media/ /extension/media
|
COPY packages/backend/media/ /extension/media
|
||||||
COPY LICENSE /extension/
|
COPY LICENSE /extension/
|
||||||
COPY packages/backend/icon.png /extension/
|
COPY packages/backend/icon.png /extension/
|
||||||
|
COPY packages/backend/brain.woff2 /extension/
|
||||||
COPY README.md /extension/
|
COPY README.md /extension/
|
||||||
COPY api/openapi.yaml /extension/api/
|
COPY api/openapi.yaml /extension/api/
|
||||||
|
|
||||||
|
|
43
package.json
43
package.json
|
@ -3,7 +3,7 @@
|
||||||
"displayName": "ai-lab-monorepo",
|
"displayName": "ai-lab-monorepo",
|
||||||
"description": "ai-lab-monorepo",
|
"description": "ai-lab-monorepo",
|
||||||
"publisher": "redhat",
|
"publisher": "redhat",
|
||||||
"version": "1.7.0-next",
|
"version": "1.9.0-next",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
|
@ -24,6 +24,7 @@
|
||||||
"test:unit": "pnpm run test:backend && pnpm run test:shared && pnpm run test:frontend",
|
"test:unit": "pnpm run test:backend && pnpm run test:shared && pnpm run test:frontend",
|
||||||
"test:e2e": "cd tests/playwright && pnpm run test:e2e",
|
"test:e2e": "cd tests/playwright && pnpm run test:e2e",
|
||||||
"test:e2e:smoke": "cd tests/playwright && pnpm run test:e2e:smoke",
|
"test:e2e:smoke": "cd tests/playwright && pnpm run test:e2e:smoke",
|
||||||
|
"test:e2e:instructlab": "cd tests/playwright && pnpm run test:e2e:instructlab",
|
||||||
"typecheck:shared": "tsc --noEmit --project packages/shared",
|
"typecheck:shared": "tsc --noEmit --project packages/shared",
|
||||||
"typecheck:frontend": "tsc --noEmit --project packages/frontend",
|
"typecheck:frontend": "tsc --noEmit --project packages/frontend",
|
||||||
"typecheck:backend": "cd packages/backend && pnpm run typecheck",
|
"typecheck:backend": "cd packages/backend && pnpm run typecheck",
|
||||||
|
@ -45,14 +46,14 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "^19.8.1",
|
"@commitlint/cli": "^19.8.1",
|
||||||
"@commitlint/config-conventional": "^19.8.1",
|
"@commitlint/config-conventional": "^19.8.1",
|
||||||
"@eslint/compat": "^1.2.9",
|
"@eslint/compat": "^1.3.2",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.32.1",
|
"@typescript-eslint/eslint-plugin": "^8.40.0",
|
||||||
"@typescript-eslint/parser": "^8.32.1",
|
"@typescript-eslint/parser": "^8.40.0",
|
||||||
"@vitest/coverage-v8": "^3.0.5",
|
"@vitest/coverage-v8": "^3.2.3",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"commitlint": "^19.8.1",
|
"commitlint": "^19.8.1",
|
||||||
"concurrently": "^9.1.2",
|
"concurrently": "^9.1.2",
|
||||||
"eslint": "^9.27.0",
|
"eslint": "^9.33.0",
|
||||||
"eslint-import-resolver-custom-alias": "^1.3.2",
|
"eslint-import-resolver-custom-alias": "^1.3.2",
|
||||||
"eslint-import-resolver-typescript": "^4.3.5",
|
"eslint-import-resolver-typescript": "^4.3.5",
|
||||||
"eslint-plugin-etc": "^2.0.3",
|
"eslint-plugin-etc": "^2.0.3",
|
||||||
|
@ -60,19 +61,19 @@
|
||||||
"eslint-plugin-no-null": "^1.0.2",
|
"eslint-plugin-no-null": "^1.0.2",
|
||||||
"eslint-plugin-redundant-undefined": "^1.0.0",
|
"eslint-plugin-redundant-undefined": "^1.0.0",
|
||||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||||
"eslint-plugin-sonarjs": "^3.0.2",
|
"eslint-plugin-sonarjs": "^3.0.3",
|
||||||
"eslint-plugin-svelte": "^3.8.1",
|
"eslint-plugin-svelte": "^3.11.0",
|
||||||
"eslint-plugin-unicorn": "^59.0.1",
|
"eslint-plugin-unicorn": "^60.0.0",
|
||||||
"globals": "^16.1.0",
|
"globals": "^16.1.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^16.0.0",
|
"lint-staged": "^16.1.5",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"prettier-plugin-svelte": "^3.4.0",
|
"prettier-plugin-svelte": "^3.4.0",
|
||||||
"svelte-check": "^4.2.1",
|
"svelte-check": "^4.3.1",
|
||||||
"svelte-eslint-parser": "^1.2.0",
|
"svelte-eslint-parser": "^1.3.1",
|
||||||
"typescript": "5.8.3",
|
"typescript": "5.9.2",
|
||||||
"typescript-eslint": "^8.32.1",
|
"typescript-eslint": "^8.40.0",
|
||||||
"vite": "^6.3.5",
|
"vite": "^7.1.3",
|
||||||
"vitest": "^3.0.5"
|
"vitest": "^3.0.5"
|
||||||
},
|
},
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
|
@ -90,7 +91,15 @@
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"postman-collection>semver": "^7.5.2"
|
"postman-collection>semver": "^7.5.2"
|
||||||
}
|
},
|
||||||
|
"ignoredBuiltDependencies": [
|
||||||
|
"@scarf/scarf",
|
||||||
|
"@tailwindcss/oxide",
|
||||||
|
"esbuild",
|
||||||
|
"postman-code-generators",
|
||||||
|
"svelte-preprocess",
|
||||||
|
"unrs-resolver"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@9.9.0+sha512.60c18acd138bff695d339be6ad13f7e936eea6745660d4cc4a776d5247c540d0edee1a563695c183a66eb917ef88f2b4feb1fc25f32a7adcadc7aaf3438e99c1"
|
"packageManager": "pnpm@10.12.4+sha512.5ea8b0deed94ed68691c9bad4c955492705c5eeb8a87ef86bc62c74a26b037b08ff9570f108b2e4dbd1dd1a9186fea925e527f141c648e85af45631074680184"
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
"name": "ai-lab",
|
"name": "ai-lab",
|
||||||
"displayName": "Podman AI Lab",
|
"displayName": "Podman AI Lab",
|
||||||
"description": "Podman AI Lab lets you work with LLMs locally, exploring AI fundamentals, experimenting with models and prompts, and serving models while maintaining data security and privacy.",
|
"description": "Podman AI Lab lets you work with LLMs locally, exploring AI fundamentals, experimenting with models and prompts, and serving models while maintaining data security and privacy.",
|
||||||
"version": "1.7.0-next",
|
"version": "1.9.0-next",
|
||||||
"icon": "icon.png",
|
"icon": "icon.png",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"publisher": "redhat",
|
"publisher": "redhat",
|
||||||
|
@ -110,22 +110,22 @@
|
||||||
"typecheck": "pnpm run generate && tsc --noEmit"
|
"typecheck": "pnpm run generate && tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/openai-compatible": "^0.2.14",
|
"@ai-sdk/openai-compatible": "^0.2.16",
|
||||||
"@huggingface/gguf": "^0.1.17",
|
"@huggingface/gguf": "^0.2.1",
|
||||||
"@huggingface/hub": "^2.1.0",
|
"@huggingface/hub": "^2.4.1",
|
||||||
"ai": "^4.3.16",
|
"ai": "^4.3.19",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"express-openapi-validator": "^5.5.1",
|
"express-openapi-validator": "^5.5.8",
|
||||||
"isomorphic-git": "^1.30.1",
|
"isomorphic-git": "^1.33.0",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"mustache": "^4.2.0",
|
"mustache": "^4.2.0",
|
||||||
"openai": "^4.99.0",
|
"openai": "^5.15.0",
|
||||||
"postman-code-generators": "^1.14.1",
|
"postman-code-generators": "^1.14.1",
|
||||||
"postman-collection": "^5.0.2",
|
"postman-collection": "^5.1.0",
|
||||||
"semver": "^7.7.2",
|
"semver": "^7.7.2",
|
||||||
"swagger-ui-dist": "^5.21.0",
|
"swagger-ui-dist": "^5.27.1",
|
||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
"systeminformation": "^5.25.11",
|
"systeminformation": "^5.27.7",
|
||||||
"xml-js": "^1.6.11"
|
"xml-js": "^1.6.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
@ -140,8 +140,8 @@
|
||||||
"@types/supertest": "^6.0.3",
|
"@types/supertest": "^6.0.3",
|
||||||
"@types/swagger-ui-dist": "^3.30.5",
|
"@types/swagger-ui-dist": "^3.30.5",
|
||||||
"@types/swagger-ui-express": "^4.1.8",
|
"@types/swagger-ui-express": "^4.1.8",
|
||||||
"openapi-typescript": "^7.8.0",
|
"openapi-typescript": "^7.9.1",
|
||||||
"supertest": "^7.1.1",
|
"supertest": "^7.1.4",
|
||||||
"vitest": "^3.0.5"
|
"vitest": "^3.0.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
File diff suppressed because one or more lines are too long
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"whispercpp": {
|
"whispercpp": {
|
||||||
"default": "quay.io/ramalama/ramalama-whisper-server@sha256:72bce4bed86e7f72e41c60960dd7b1fd9b5115328f520ddcae5dbdd689376995"
|
"default": "quay.io/ramalama/ramalama-whisper-server@sha256:010aa34d8734e5e698fb4c5e852e43e5909baa928e3b6e991e1038a1973909ba"
|
||||||
},
|
},
|
||||||
"llamacpp": {
|
"llamacpp": {
|
||||||
"default": "quay.io/ramalama/ramalama-llama-server@sha256:4e56101073e0bd6f2f2e15839b64315656d0dbfc1331a3385f2ae722e13f2279",
|
"default": "quay.io/ramalama/ramalama-llama-server@sha256:4409a5c964382408f3bc08be1314754edaf2dfec1626f31974e34379bfeec41e",
|
||||||
"cuda": "quay.io/ramalama/cuda-llama-server@sha256:56efc824e5b3ae6a6a11e9537ed9e2ac05f9f9fc6f2e81a55eb67b662c94fe95"
|
"cuda": "quay.io/ramalama/cuda-llama-server@sha256:5e1a3a2508e4b802c8d8c3ecb97ad1778a1b4288fd114562b51fd411bad91841"
|
||||||
},
|
},
|
||||||
"openvino": {
|
"openvino": {
|
||||||
"default": "quay.io/ramalama/openvino@sha256:670d91cc322933cc4263606459317cd4ca3fcfb16d59a46b11dcd498c2cd7cb5"
|
"default": "quay.io/ramalama/openvino@sha256:705f3e0a44dcdc2c7b81c3931e42d5ee19d2502bdb5ebddf3f186932a2658e83"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
{
|
{
|
||||||
"default": "quay.io/podman-ai-lab/distribution-podman-ai-lab@sha256:12a86f62e8623aaeb2a86120a77d274c0e52496d307d2a399969cc1f8f5260c5"
|
"default": "ghcr.io/containers/podman-ai-lab-stack:8d6a4a9a7c587c0a8e44703dd750355256e7a796"
|
||||||
}
|
}
|
||||||
|
|
|
@ -65,6 +65,8 @@ const modelsManager = {
|
||||||
getModelsInfo: vi.fn(),
|
getModelsInfo: vi.fn(),
|
||||||
isModelOnDisk: vi.fn(),
|
isModelOnDisk: vi.fn(),
|
||||||
createDownloader: vi.fn(),
|
createDownloader: vi.fn(),
|
||||||
|
getLocalModelsFromDisk: vi.fn(),
|
||||||
|
sendModelsInfo: vi.fn(),
|
||||||
} as unknown as ModelsManager;
|
} as unknown as ModelsManager;
|
||||||
|
|
||||||
const catalogManager = {
|
const catalogManager = {
|
||||||
|
@ -278,6 +280,8 @@ describe.each([undefined, true, false])('/api/pull endpoint, stream is %o', stre
|
||||||
});
|
});
|
||||||
|
|
||||||
test('/api/pull downloads model and returns success', async () => {
|
test('/api/pull downloads model and returns success', async () => {
|
||||||
|
const getLocalModelsSpy = vi.spyOn(modelsManager, 'getLocalModelsFromDisk').mockResolvedValue();
|
||||||
|
const sendModelsInfoSpy = vi.spyOn(modelsManager, 'sendModelsInfo').mockResolvedValue();
|
||||||
expect(server.getListener()).toBeDefined();
|
expect(server.getListener()).toBeDefined();
|
||||||
vi.mocked(catalogManager.getModelByName).mockReturnValue({
|
vi.mocked(catalogManager.getModelByName).mockReturnValue({
|
||||||
id: 'modelId',
|
id: 'modelId',
|
||||||
|
@ -312,6 +316,8 @@ describe.each([undefined, true, false])('/api/pull endpoint, stream is %o', stre
|
||||||
expect(lines[2]).toEqual('{"status":"success"}');
|
expect(lines[2]).toEqual('{"status":"success"}');
|
||||||
expect(lines[3]).toEqual('');
|
expect(lines[3]).toEqual('');
|
||||||
}
|
}
|
||||||
|
expect(getLocalModelsSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(sendModelsInfoSpy).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('/api/pull should return an error if an error occurs during download', async () => {
|
test('/api/pull should return an error if an error occurs during download', async () => {
|
||||||
|
|
|
@ -342,7 +342,9 @@ export class ApiServer implements Disposable {
|
||||||
|
|
||||||
downloader
|
downloader
|
||||||
.perform(modelName)
|
.perform(modelName)
|
||||||
.then(() => {
|
.then(async () => {
|
||||||
|
await this.modelsManager.getLocalModelsFromDisk();
|
||||||
|
await this.modelsManager.sendModelsInfo();
|
||||||
this.sendResult(
|
this.sendResult(
|
||||||
res,
|
res,
|
||||||
{
|
{
|
||||||
|
@ -505,7 +507,7 @@ export class ApiServer implements Disposable {
|
||||||
res.write(
|
res.write(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
model: modelName,
|
model: modelName,
|
||||||
response: chunk.choices[0].delta.content,
|
response: chunk.choices[0].delta.content ?? '',
|
||||||
done: chunk.choices[0].finish_reason === 'stop',
|
done: chunk.choices[0].finish_reason === 'stop',
|
||||||
done_reason: chunk.choices[0].finish_reason === 'stop' ? 'stop' : undefined,
|
done_reason: chunk.choices[0].finish_reason === 'stop' ? 'stop' : undefined,
|
||||||
}) + '\n',
|
}) + '\n',
|
||||||
|
@ -516,7 +518,7 @@ export class ApiServer implements Disposable {
|
||||||
onNonStreamResponse: response => {
|
onNonStreamResponse: response => {
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
model: modelName,
|
model: modelName,
|
||||||
response: response.choices[0].message.content,
|
response: response.choices[0].message.content ?? '',
|
||||||
done: true,
|
done: true,
|
||||||
done_reason: 'stop',
|
done_reason: 'stop',
|
||||||
});
|
});
|
||||||
|
@ -571,7 +573,7 @@ export class ApiServer implements Disposable {
|
||||||
model: modelName,
|
model: modelName,
|
||||||
message: {
|
message: {
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: chunk.choices[0].delta.content,
|
content: chunk.choices[0].delta.content ?? '',
|
||||||
},
|
},
|
||||||
done: chunk.choices[0].finish_reason === 'stop',
|
done: chunk.choices[0].finish_reason === 'stop',
|
||||||
done_reason: chunk.choices[0].finish_reason === 'stop' ? 'stop' : undefined,
|
done_reason: chunk.choices[0].finish_reason === 'stop' ? 'stop' : undefined,
|
||||||
|
@ -585,7 +587,7 @@ export class ApiServer implements Disposable {
|
||||||
model: modelName,
|
model: modelName,
|
||||||
message: {
|
message: {
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: response.choices[0].message.content,
|
content: response.choices[0].message.content ?? '',
|
||||||
},
|
},
|
||||||
done: true,
|
done: true,
|
||||||
done_reason: 'stop',
|
done_reason: 'stop',
|
||||||
|
|
|
@ -31,6 +31,8 @@ import { VMType } from '@shared/models/IPodman';
|
||||||
import { POD_LABEL_MODEL_ID, POD_LABEL_RECIPE_ID } from '../../utils/RecipeConstants';
|
import { POD_LABEL_MODEL_ID, POD_LABEL_RECIPE_ID } from '../../utils/RecipeConstants';
|
||||||
import type { InferenceServer } from '@shared/models/IInference';
|
import type { InferenceServer } from '@shared/models/IInference';
|
||||||
import type { RpcExtension } from '@shared/messages/MessageProxy';
|
import type { RpcExtension } from '@shared/messages/MessageProxy';
|
||||||
|
import type { LlamaStackManager } from '../llama-stack/llamaStackManager';
|
||||||
|
import type { ApplicationOptions } from '../../models/ApplicationOptions';
|
||||||
|
|
||||||
const taskRegistryMock = {
|
const taskRegistryMock = {
|
||||||
createTask: vi.fn(),
|
createTask: vi.fn(),
|
||||||
|
@ -75,6 +77,10 @@ const recipeManager = {
|
||||||
buildRecipe: vi.fn(),
|
buildRecipe: vi.fn(),
|
||||||
} as unknown as RecipeManager;
|
} as unknown as RecipeManager;
|
||||||
|
|
||||||
|
const llamaStackManager = {
|
||||||
|
getLlamaStackContainer: vi.fn(),
|
||||||
|
} as unknown as LlamaStackManager;
|
||||||
|
|
||||||
vi.mock('@podman-desktop/api', () => ({
|
vi.mock('@podman-desktop/api', () => ({
|
||||||
window: {
|
window: {
|
||||||
withProgress: vi.fn(),
|
withProgress: vi.fn(),
|
||||||
|
@ -139,6 +145,11 @@ beforeEach(() => {
|
||||||
id: 'fake-task',
|
id: 'fake-task',
|
||||||
}));
|
}));
|
||||||
vi.mocked(modelsManagerMock.uploadModelToPodmanMachine).mockResolvedValue('downloaded-model-path');
|
vi.mocked(modelsManagerMock.uploadModelToPodmanMachine).mockResolvedValue('downloaded-model-path');
|
||||||
|
vi.mocked(llamaStackManager.getLlamaStackContainer).mockResolvedValue({
|
||||||
|
containerId: 'container1',
|
||||||
|
port: 10001,
|
||||||
|
playgroundPort: 10002,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function getInitializedApplicationManager(): ApplicationManager {
|
function getInitializedApplicationManager(): ApplicationManager {
|
||||||
|
@ -151,6 +162,7 @@ function getInitializedApplicationManager(): ApplicationManager {
|
||||||
telemetryMock,
|
telemetryMock,
|
||||||
podManager,
|
podManager,
|
||||||
recipeManager,
|
recipeManager,
|
||||||
|
llamaStackManager,
|
||||||
);
|
);
|
||||||
|
|
||||||
manager.init();
|
manager.init();
|
||||||
|
@ -160,11 +172,11 @@ function getInitializedApplicationManager(): ApplicationManager {
|
||||||
describe('requestPullApplication', () => {
|
describe('requestPullApplication', () => {
|
||||||
test('task should be set to error if pull application raise an error', async () => {
|
test('task should be set to error if pull application raise an error', async () => {
|
||||||
vi.mocked(window.withProgress).mockRejectedValue(new Error('pull application error'));
|
vi.mocked(window.withProgress).mockRejectedValue(new Error('pull application error'));
|
||||||
const trackingId = await getInitializedApplicationManager().requestPullApplication(
|
const trackingId = await getInitializedApplicationManager().requestPullApplication({
|
||||||
connectionMock,
|
connection: connectionMock,
|
||||||
recipeMock,
|
recipe: recipeMock,
|
||||||
remoteModelMock,
|
model: remoteModelMock,
|
||||||
);
|
});
|
||||||
|
|
||||||
// ensure the task is created
|
// ensure the task is created
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
|
@ -290,40 +302,67 @@ describe('startApplication', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('pullApplication', () => {
|
describe.each([true, false])('pullApplication, with model is %o', withModel => {
|
||||||
|
let applicationOptions: ApplicationOptions;
|
||||||
|
beforeEach(() => {
|
||||||
|
applicationOptions = withModel
|
||||||
|
? {
|
||||||
|
connection: connectionMock,
|
||||||
|
recipe: recipeMock,
|
||||||
|
model: remoteModelMock,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
connection: connectionMock,
|
||||||
|
recipe: recipeMock,
|
||||||
|
dependencies: {
|
||||||
|
llamaStack: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
test('labels should be propagated', async () => {
|
test('labels should be propagated', async () => {
|
||||||
await getInitializedApplicationManager().pullApplication(connectionMock, recipeMock, remoteModelMock, {
|
await getInitializedApplicationManager().pullApplication(applicationOptions, {
|
||||||
'test-label': 'test-value',
|
'test-label': 'test-value',
|
||||||
});
|
});
|
||||||
|
|
||||||
// clone the recipe
|
// clone the recipe
|
||||||
expect(recipeManager.cloneRecipe).toHaveBeenCalledWith(recipeMock, {
|
expect(recipeManager.cloneRecipe).toHaveBeenCalledWith(recipeMock, {
|
||||||
'test-label': 'test-value',
|
'test-label': 'test-value',
|
||||||
'model-id': remoteModelMock.id,
|
'model-id': withModel ? remoteModelMock.id : '<none>',
|
||||||
});
|
|
||||||
// download model
|
|
||||||
expect(modelsManagerMock.requestDownloadModel).toHaveBeenCalledWith(remoteModelMock, {
|
|
||||||
'test-label': 'test-value',
|
|
||||||
'recipe-id': recipeMock.id,
|
|
||||||
'model-id': remoteModelMock.id,
|
|
||||||
});
|
|
||||||
// upload model to podman machine
|
|
||||||
expect(modelsManagerMock.uploadModelToPodmanMachine).toHaveBeenCalledWith(connectionMock, remoteModelMock, {
|
|
||||||
'test-label': 'test-value',
|
|
||||||
'recipe-id': recipeMock.id,
|
|
||||||
'model-id': remoteModelMock.id,
|
|
||||||
});
|
});
|
||||||
|
if (withModel) {
|
||||||
|
// download model
|
||||||
|
expect(modelsManagerMock.requestDownloadModel).toHaveBeenCalledWith(remoteModelMock, {
|
||||||
|
'test-label': 'test-value',
|
||||||
|
'recipe-id': recipeMock.id,
|
||||||
|
'model-id': remoteModelMock.id,
|
||||||
|
});
|
||||||
|
// upload model to podman machine
|
||||||
|
expect(modelsManagerMock.uploadModelToPodmanMachine).toHaveBeenCalledWith(connectionMock, remoteModelMock, {
|
||||||
|
'test-label': 'test-value',
|
||||||
|
'recipe-id': recipeMock.id,
|
||||||
|
'model-id': remoteModelMock.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
// build the recipe
|
// build the recipe
|
||||||
expect(recipeManager.buildRecipe).toHaveBeenCalledWith(connectionMock, recipeMock, remoteModelMock, {
|
expect(recipeManager.buildRecipe).toHaveBeenCalledWith(
|
||||||
'test-label': 'test-value',
|
{
|
||||||
'recipe-id': recipeMock.id,
|
connection: connectionMock,
|
||||||
'model-id': remoteModelMock.id,
|
recipe: recipeMock,
|
||||||
});
|
model: withModel ? remoteModelMock : undefined,
|
||||||
|
dependencies: applicationOptions.dependencies,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'test-label': 'test-value',
|
||||||
|
'recipe-id': recipeMock.id,
|
||||||
|
'model-id': withModel ? remoteModelMock.id : '<none>',
|
||||||
|
},
|
||||||
|
);
|
||||||
// create AI App task must be created
|
// create AI App task must be created
|
||||||
expect(taskRegistryMock.createTask).toHaveBeenCalledWith('Creating AI App', 'loading', {
|
expect(taskRegistryMock.createTask).toHaveBeenCalledWith('Creating AI App', 'loading', {
|
||||||
'test-label': 'test-value',
|
'test-label': 'test-value',
|
||||||
'recipe-id': recipeMock.id,
|
'recipe-id': recipeMock.id,
|
||||||
'model-id': remoteModelMock.id,
|
'model-id': withModel ? remoteModelMock.id : '<none>',
|
||||||
});
|
});
|
||||||
|
|
||||||
// a pod must have been created
|
// a pod must have been created
|
||||||
|
@ -332,7 +371,7 @@ describe('pullApplication', () => {
|
||||||
name: expect.any(String),
|
name: expect.any(String),
|
||||||
portmappings: [],
|
portmappings: [],
|
||||||
labels: {
|
labels: {
|
||||||
[POD_LABEL_MODEL_ID]: remoteModelMock.id,
|
[POD_LABEL_MODEL_ID]: withModel ? remoteModelMock.id : '<none>',
|
||||||
[POD_LABEL_RECIPE_ID]: recipeMock.id,
|
[POD_LABEL_RECIPE_ID]: recipeMock.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -340,7 +379,7 @@ describe('pullApplication', () => {
|
||||||
expect(containerEngine.createContainer).toHaveBeenCalledWith('test-engine-id', {
|
expect(containerEngine.createContainer).toHaveBeenCalledWith('test-engine-id', {
|
||||||
Image: recipeImageInfoMock.id,
|
Image: recipeImageInfoMock.id,
|
||||||
name: expect.any(String),
|
name: expect.any(String),
|
||||||
Env: [],
|
Env: withModel ? [] : ['MODEL_ENDPOINT=http://host.containers.internal:10001'],
|
||||||
HealthCheck: undefined,
|
HealthCheck: undefined,
|
||||||
HostConfig: undefined,
|
HostConfig: undefined,
|
||||||
Detach: true,
|
Detach: true,
|
||||||
|
@ -361,34 +400,45 @@ describe('pullApplication', () => {
|
||||||
},
|
},
|
||||||
} as InferenceServer,
|
} as InferenceServer,
|
||||||
});
|
});
|
||||||
await getInitializedApplicationManager().pullApplication(connectionMock, recipeMock, remoteModelMock, {
|
vi.mocked(modelsManagerMock.requestDownloadModel).mockResolvedValue('/path/to/model');
|
||||||
|
await getInitializedApplicationManager().pullApplication(applicationOptions, {
|
||||||
'test-label': 'test-value',
|
'test-label': 'test-value',
|
||||||
});
|
});
|
||||||
|
|
||||||
// clone the recipe
|
// clone the recipe
|
||||||
expect(recipeManager.cloneRecipe).toHaveBeenCalledWith(recipeMock, {
|
expect(recipeManager.cloneRecipe).toHaveBeenCalledWith(recipeMock, {
|
||||||
'test-label': 'test-value',
|
'test-label': 'test-value',
|
||||||
'model-id': remoteModelMock.id,
|
'model-id': withModel ? remoteModelMock.id : '<none>',
|
||||||
});
|
});
|
||||||
// download model
|
if (withModel) {
|
||||||
expect(modelsManagerMock.requestDownloadModel).toHaveBeenCalledWith(remoteModelMock, {
|
// download model
|
||||||
'test-label': 'test-value',
|
expect(modelsManagerMock.requestDownloadModel).toHaveBeenCalledWith(remoteModelMock, {
|
||||||
'recipe-id': recipeMock.id,
|
'test-label': 'test-value',
|
||||||
'model-id': remoteModelMock.id,
|
'recipe-id': recipeMock.id,
|
||||||
});
|
'model-id': remoteModelMock.id,
|
||||||
// upload model to podman machine
|
});
|
||||||
expect(modelsManagerMock.uploadModelToPodmanMachine).not.toHaveBeenCalled();
|
// upload model to podman machine
|
||||||
|
expect(modelsManagerMock.uploadModelToPodmanMachine).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
// build the recipe
|
// build the recipe
|
||||||
expect(recipeManager.buildRecipe).toHaveBeenCalledWith(connectionMock, recipeMock, remoteModelMock, {
|
expect(recipeManager.buildRecipe).toHaveBeenCalledWith(
|
||||||
'test-label': 'test-value',
|
{
|
||||||
'recipe-id': recipeMock.id,
|
connection: connectionMock,
|
||||||
'model-id': remoteModelMock.id,
|
recipe: recipeMock,
|
||||||
});
|
model: withModel ? remoteModelMock : undefined,
|
||||||
|
dependencies: applicationOptions.dependencies,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'test-label': 'test-value',
|
||||||
|
'recipe-id': recipeMock.id,
|
||||||
|
'model-id': withModel ? remoteModelMock.id : '<none>',
|
||||||
|
},
|
||||||
|
);
|
||||||
// create AI App task must be created
|
// create AI App task must be created
|
||||||
expect(taskRegistryMock.createTask).toHaveBeenCalledWith('Creating AI App', 'loading', {
|
expect(taskRegistryMock.createTask).toHaveBeenCalledWith('Creating AI App', 'loading', {
|
||||||
'test-label': 'test-value',
|
'test-label': 'test-value',
|
||||||
'recipe-id': recipeMock.id,
|
'recipe-id': recipeMock.id,
|
||||||
'model-id': remoteModelMock.id,
|
'model-id': withModel ? remoteModelMock.id : '<none>',
|
||||||
});
|
});
|
||||||
|
|
||||||
// a pod must have been created
|
// a pod must have been created
|
||||||
|
@ -397,7 +447,7 @@ describe('pullApplication', () => {
|
||||||
name: expect.any(String),
|
name: expect.any(String),
|
||||||
portmappings: [],
|
portmappings: [],
|
||||||
labels: {
|
labels: {
|
||||||
[POD_LABEL_MODEL_ID]: remoteModelMock.id,
|
[POD_LABEL_MODEL_ID]: withModel ? remoteModelMock.id : '<none>',
|
||||||
[POD_LABEL_RECIPE_ID]: recipeMock.id,
|
[POD_LABEL_RECIPE_ID]: recipeMock.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -405,7 +455,9 @@ describe('pullApplication', () => {
|
||||||
expect(containerEngine.createContainer).toHaveBeenCalledWith('test-engine-id', {
|
expect(containerEngine.createContainer).toHaveBeenCalledWith('test-engine-id', {
|
||||||
Image: recipeImageInfoMock.id,
|
Image: recipeImageInfoMock.id,
|
||||||
name: expect.any(String),
|
name: expect.any(String),
|
||||||
Env: ['MODEL_ENDPOINT=http://host.containers.internal:56001'],
|
Env: withModel
|
||||||
|
? ['MODEL_ENDPOINT=http://host.containers.internal:56001']
|
||||||
|
: ['MODEL_ENDPOINT=http://host.containers.internal:10001'],
|
||||||
HealthCheck: undefined,
|
HealthCheck: undefined,
|
||||||
HostConfig: undefined,
|
HostConfig: undefined,
|
||||||
Detach: true,
|
Detach: true,
|
||||||
|
@ -427,12 +479,12 @@ describe('pullApplication', () => {
|
||||||
},
|
},
|
||||||
} as unknown as PodInfo);
|
} as unknown as PodInfo);
|
||||||
|
|
||||||
await getInitializedApplicationManager().pullApplication(connectionMock, recipeMock, remoteModelMock);
|
await getInitializedApplicationManager().pullApplication(applicationOptions);
|
||||||
|
|
||||||
// removing existing application should create a task to notify the user
|
// removing existing application should create a task to notify the user
|
||||||
expect(taskRegistryMock.createTask).toHaveBeenCalledWith('Removing AI App', 'loading', {
|
expect(taskRegistryMock.createTask).toHaveBeenCalledWith('Removing AI App', 'loading', {
|
||||||
'recipe-id': recipeMock.id,
|
'recipe-id': recipeMock.id,
|
||||||
'model-id': remoteModelMock.id,
|
'model-id': withModel ? remoteModelMock.id : '<none>',
|
||||||
});
|
});
|
||||||
// the remove pod should have been called
|
// the remove pod should have been called
|
||||||
expect(podManager.removePod).toHaveBeenCalledWith('test-engine-id', 'test-pod-id-existing');
|
expect(podManager.removePod).toHaveBeenCalledWith('test-engine-id', 'test-pod-id-existing');
|
||||||
|
@ -456,22 +508,24 @@ describe('pullApplication', () => {
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
await getInitializedApplicationManager().pullApplication(connectionMock, recipeMock, remoteModelMock);
|
await getInitializedApplicationManager().pullApplication(applicationOptions);
|
||||||
|
|
||||||
// the remove pod should have been called
|
// the remove pod should have been called
|
||||||
expect(containerEngine.createContainer).toHaveBeenCalledWith(
|
expect(containerEngine.createContainer).toHaveBeenCalledWith(
|
||||||
recipeImageInfoMock.engineId,
|
recipeImageInfoMock.engineId,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
HostConfig: {
|
HostConfig: withModel
|
||||||
Mounts: [
|
? {
|
||||||
{
|
Mounts: [
|
||||||
Mode: 'Z',
|
{
|
||||||
Source: 'downloaded-model-path',
|
Mode: 'Z',
|
||||||
Target: '/downloaded-model-path',
|
Source: 'downloaded-model-path',
|
||||||
Type: 'bind',
|
Target: '/downloaded-model-path',
|
||||||
},
|
Type: 'bind',
|
||||||
],
|
},
|
||||||
},
|
],
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
@ -16,7 +16,7 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
***********************************************************************/
|
***********************************************************************/
|
||||||
|
|
||||||
import type { Recipe, RecipeComponents, RecipeImage } from '@shared/models/IRecipe';
|
import type { RecipeComponents, RecipeImage } from '@shared/models/IRecipe';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { containerEngine, Disposable, window, ProgressLocation } from '@podman-desktop/api';
|
import { containerEngine, Disposable, window, ProgressLocation } from '@podman-desktop/api';
|
||||||
import type {
|
import type {
|
||||||
|
@ -28,7 +28,6 @@ import type {
|
||||||
PodContainerInfo,
|
PodContainerInfo,
|
||||||
ContainerProviderConnection,
|
ContainerProviderConnection,
|
||||||
} from '@podman-desktop/api';
|
} from '@podman-desktop/api';
|
||||||
import type { ModelInfo } from '@shared/models/IModelInfo';
|
|
||||||
import type { ModelsManager } from '../modelsManager';
|
import type { ModelsManager } from '../modelsManager';
|
||||||
import { getPortsFromLabel, getPortsInfo } from '../../utils/ports';
|
import { getPortsFromLabel, getPortsInfo } from '../../utils/ports';
|
||||||
import { getDurationSecondsSince, timeout } from '../../utils/utils';
|
import { getDurationSecondsSince, timeout } from '../../utils/utils';
|
||||||
|
@ -55,6 +54,8 @@ import { RECIPE_START_ROUTE } from '../../registries/NavigationRegistry';
|
||||||
import type { RpcExtension } from '@shared/messages/MessageProxy';
|
import type { RpcExtension } from '@shared/messages/MessageProxy';
|
||||||
import { TaskRunner } from '../TaskRunner';
|
import { TaskRunner } from '../TaskRunner';
|
||||||
import { getInferenceType } from '../../utils/inferenceUtils';
|
import { getInferenceType } from '../../utils/inferenceUtils';
|
||||||
|
import type { LlamaStackManager } from '../llama-stack/llamaStackManager';
|
||||||
|
import { isApplicationOptionsWithModelInference, type ApplicationOptions } from '../../models/ApplicationOptions';
|
||||||
|
|
||||||
export class ApplicationManager extends Publisher<ApplicationState[]> implements Disposable {
|
export class ApplicationManager extends Publisher<ApplicationState[]> implements Disposable {
|
||||||
#applications: ApplicationRegistry<ApplicationState>;
|
#applications: ApplicationRegistry<ApplicationState>;
|
||||||
|
@ -71,6 +72,7 @@ export class ApplicationManager extends Publisher<ApplicationState[]> implements
|
||||||
private telemetry: TelemetryLogger,
|
private telemetry: TelemetryLogger,
|
||||||
private podManager: PodManager,
|
private podManager: PodManager,
|
||||||
private recipeManager: RecipeManager,
|
private recipeManager: RecipeManager,
|
||||||
|
private llamaStackManager: LlamaStackManager,
|
||||||
) {
|
) {
|
||||||
super(rpcExtension, MSG_APPLICATIONS_STATE_UPDATE, () => this.getApplicationsState());
|
super(rpcExtension, MSG_APPLICATIONS_STATE_UPDATE, () => this.getApplicationsState());
|
||||||
this.#applications = new ApplicationRegistry<ApplicationState>();
|
this.#applications = new ApplicationRegistry<ApplicationState>();
|
||||||
|
@ -78,11 +80,7 @@ export class ApplicationManager extends Publisher<ApplicationState[]> implements
|
||||||
this.#disposables = [];
|
this.#disposables = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async requestPullApplication(
|
async requestPullApplication(options: ApplicationOptions): Promise<string> {
|
||||||
connection: ContainerProviderConnection,
|
|
||||||
recipe: Recipe,
|
|
||||||
model: ModelInfo,
|
|
||||||
): Promise<string> {
|
|
||||||
// create a tracking id to put in the labels
|
// create a tracking id to put in the labels
|
||||||
const trackingId: string = getRandomString();
|
const trackingId: string = getRandomString();
|
||||||
|
|
||||||
|
@ -94,23 +92,23 @@ export class ApplicationManager extends Publisher<ApplicationState[]> implements
|
||||||
.runAsTask(
|
.runAsTask(
|
||||||
{
|
{
|
||||||
...labels,
|
...labels,
|
||||||
'recipe-pulling': recipe.id, // this label should only be on the master task
|
'recipe-pulling': options.recipe.id, // this label should only be on the master task
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
loadingLabel: `Pulling ${recipe.name} recipe`,
|
loadingLabel: `Pulling ${options.recipe.name} recipe`,
|
||||||
errorMsg: err => `Something went wrong while pulling ${recipe.name}: ${String(err)}`,
|
errorMsg: err => `Something went wrong while pulling ${options.recipe.name}: ${String(err)}`,
|
||||||
},
|
},
|
||||||
() =>
|
() =>
|
||||||
window.withProgress(
|
window.withProgress(
|
||||||
{
|
{
|
||||||
location: ProgressLocation.TASK_WIDGET,
|
location: ProgressLocation.TASK_WIDGET,
|
||||||
title: `Pulling ${recipe.name}.`,
|
title: `Pulling ${options.recipe.name}.`,
|
||||||
details: {
|
details: {
|
||||||
routeId: RECIPE_START_ROUTE,
|
routeId: RECIPE_START_ROUTE,
|
||||||
routeArgs: [recipe.id, trackingId],
|
routeArgs: [options.recipe.id, trackingId],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
() => this.pullApplication(connection, recipe, model, labels),
|
() => this.pullApplication(options, labels),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
@ -118,37 +116,43 @@ export class ApplicationManager extends Publisher<ApplicationState[]> implements
|
||||||
return trackingId;
|
return trackingId;
|
||||||
}
|
}
|
||||||
|
|
||||||
async pullApplication(
|
async pullApplication(options: ApplicationOptions, labels: Record<string, string> = {}): Promise<void> {
|
||||||
connection: ContainerProviderConnection,
|
let modelId: string;
|
||||||
recipe: Recipe,
|
if (isApplicationOptionsWithModelInference(options)) {
|
||||||
model: ModelInfo,
|
modelId = options.model.id;
|
||||||
labels: Record<string, string> = {},
|
} else {
|
||||||
): Promise<void> {
|
modelId = '<none>';
|
||||||
|
}
|
||||||
|
|
||||||
// clear any existing status / tasks related to the pair recipeId-modelId.
|
// clear any existing status / tasks related to the pair recipeId-modelId.
|
||||||
this.taskRegistry.deleteByLabels({
|
this.taskRegistry.deleteByLabels({
|
||||||
'recipe-id': recipe.id,
|
'recipe-id': options.recipe.id,
|
||||||
'model-id': model.id,
|
'model-id': modelId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
try {
|
try {
|
||||||
// init application (git clone, models download etc.)
|
// init application (git clone, models download etc.)
|
||||||
const podInfo: PodInfo = await this.initApplication(connection, recipe, model, labels);
|
const podInfo: PodInfo = await this.initApplication(options, labels);
|
||||||
// start the pod
|
// start the pod
|
||||||
await this.runApplication(podInfo, {
|
await this.runApplication(podInfo, {
|
||||||
...labels,
|
...labels,
|
||||||
'recipe-id': recipe.id,
|
'recipe-id': options.recipe.id,
|
||||||
'model-id': model.id,
|
'model-id': modelId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// measure init + start time
|
// measure init + start time
|
||||||
const durationSeconds = getDurationSecondsSince(startTime);
|
const durationSeconds = getDurationSecondsSince(startTime);
|
||||||
this.telemetry.logUsage('recipe.pull', { 'recipe.id': recipe.id, 'recipe.name': recipe.name, durationSeconds });
|
this.telemetry.logUsage('recipe.pull', {
|
||||||
|
'recipe.id': options.recipe.id,
|
||||||
|
'recipe.name': options.recipe.name,
|
||||||
|
durationSeconds,
|
||||||
|
});
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const durationSeconds = getDurationSecondsSince(startTime);
|
const durationSeconds = getDurationSecondsSince(startTime);
|
||||||
this.telemetry.logError('recipe.pull', {
|
this.telemetry.logError('recipe.pull', {
|
||||||
'recipe.id': recipe.id,
|
'recipe.id': options.recipe.id,
|
||||||
'recipe.name': recipe.name,
|
'recipe.name': options.recipe.name,
|
||||||
durationSeconds,
|
durationSeconds,
|
||||||
message: 'error pulling application',
|
message: 'error pulling application',
|
||||||
error: err,
|
error: err,
|
||||||
|
@ -173,48 +177,54 @@ export class ApplicationManager extends Publisher<ApplicationState[]> implements
|
||||||
* @param labels
|
* @param labels
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
private async initApplication(
|
private async initApplication(options: ApplicationOptions, labels: Record<string, string> = {}): Promise<PodInfo> {
|
||||||
connection: ContainerProviderConnection,
|
let modelId: string;
|
||||||
recipe: Recipe,
|
if (isApplicationOptionsWithModelInference(options)) {
|
||||||
model: ModelInfo,
|
modelId = options.model.id;
|
||||||
labels: Record<string, string> = {},
|
} else {
|
||||||
): Promise<PodInfo> {
|
modelId = '<none>';
|
||||||
|
}
|
||||||
|
|
||||||
// clone the recipe
|
// clone the recipe
|
||||||
await this.recipeManager.cloneRecipe(recipe, { ...labels, 'model-id': model.id });
|
await this.recipeManager.cloneRecipe(options.recipe, { ...labels, 'model-id': modelId });
|
||||||
|
|
||||||
// get model by downloading it or retrieving locally
|
let modelPath: string | undefined;
|
||||||
let modelPath = await this.modelsManager.requestDownloadModel(model, {
|
if (isApplicationOptionsWithModelInference(options)) {
|
||||||
...labels,
|
// get model by downloading it or retrieving locally
|
||||||
'recipe-id': recipe.id,
|
modelPath = await this.modelsManager.requestDownloadModel(options.model, {
|
||||||
'model-id': model.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
// build all images, one per container (for a basic sample we should have 2 containers = sample app + model service)
|
|
||||||
const recipeComponents = await this.recipeManager.buildRecipe(connection, recipe, model, {
|
|
||||||
...labels,
|
|
||||||
'recipe-id': recipe.id,
|
|
||||||
'model-id': model.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
// upload model to podman machine if user system is supported
|
|
||||||
if (!recipeComponents.inferenceServer) {
|
|
||||||
modelPath = await this.modelsManager.uploadModelToPodmanMachine(connection, model, {
|
|
||||||
...labels,
|
...labels,
|
||||||
'recipe-id': recipe.id,
|
'recipe-id': options.recipe.id,
|
||||||
'model-id': model.id,
|
'model-id': modelId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// build all images, one per container (for a basic sample we should have 2 containers = sample app + model service)
|
||||||
|
const recipeComponents = await this.recipeManager.buildRecipe(options, {
|
||||||
|
...labels,
|
||||||
|
'recipe-id': options.recipe.id,
|
||||||
|
'model-id': modelId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isApplicationOptionsWithModelInference(options)) {
|
||||||
|
// upload model to podman machine if user system is supported
|
||||||
|
if (!recipeComponents.inferenceServer) {
|
||||||
|
modelPath = await this.modelsManager.uploadModelToPodmanMachine(options.connection, options.model, {
|
||||||
|
...labels,
|
||||||
|
'recipe-id': options.recipe.id,
|
||||||
|
'model-id': modelId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// first delete any existing pod with matching labels
|
// first delete any existing pod with matching labels
|
||||||
if (await this.hasApplicationPod(recipe.id, model.id)) {
|
if (await this.hasApplicationPod(options.recipe.id, modelId)) {
|
||||||
await this.removeApplication(recipe.id, model.id);
|
await this.removeApplication(options.recipe.id, modelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// create a pod containing all the containers to run the application
|
// create a pod containing all the containers to run the application
|
||||||
return this.createApplicationPod(connection, recipe, model, recipeComponents, modelPath, {
|
return this.createApplicationPod(options, recipeComponents, modelPath, {
|
||||||
...labels,
|
...labels,
|
||||||
'recipe-id': recipe.id,
|
'recipe-id': options.recipe.id,
|
||||||
'model-id': model.id,
|
'model-id': modelId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -257,11 +267,9 @@ export class ApplicationManager extends Publisher<ApplicationState[]> implements
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async createApplicationPod(
|
protected async createApplicationPod(
|
||||||
connection: ContainerProviderConnection,
|
options: ApplicationOptions,
|
||||||
recipe: Recipe,
|
|
||||||
model: ModelInfo,
|
|
||||||
components: RecipeComponents,
|
components: RecipeComponents,
|
||||||
modelPath: string,
|
modelPath: string | undefined,
|
||||||
labels?: { [key: string]: string },
|
labels?: { [key: string]: string },
|
||||||
): Promise<PodInfo> {
|
): Promise<PodInfo> {
|
||||||
return this.#taskRunner.runAsTask<PodInfo>(
|
return this.#taskRunner.runAsTask<PodInfo>(
|
||||||
|
@ -271,25 +279,25 @@ export class ApplicationManager extends Publisher<ApplicationState[]> implements
|
||||||
errorMsg: err => `Something went wrong while creating pod: ${String(err)}`,
|
errorMsg: err => `Something went wrong while creating pod: ${String(err)}`,
|
||||||
},
|
},
|
||||||
async ({ updateLabels }): Promise<PodInfo> => {
|
async ({ updateLabels }): Promise<PodInfo> => {
|
||||||
const podInfo = await this.createPod(connection, recipe, model, components.images);
|
const podInfo = await this.createPod(options, components.images);
|
||||||
updateLabels(labels => ({
|
updateLabels(labels => ({
|
||||||
...labels,
|
...labels,
|
||||||
'pod-id': podInfo.Id,
|
'pod-id': podInfo.Id,
|
||||||
}));
|
}));
|
||||||
await this.createContainerAndAttachToPod(connection, podInfo, components, model, modelPath);
|
await this.createContainerAndAttachToPod(options, podInfo, components, modelPath, labels);
|
||||||
return podInfo;
|
return podInfo;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async createContainerAndAttachToPod(
|
protected async createContainerAndAttachToPod(
|
||||||
connection: ContainerProviderConnection,
|
options: ApplicationOptions,
|
||||||
podInfo: PodInfo,
|
podInfo: PodInfo,
|
||||||
components: RecipeComponents,
|
components: RecipeComponents,
|
||||||
modelInfo: ModelInfo,
|
modelPath: string | undefined,
|
||||||
modelPath: string,
|
labels?: { [key: string]: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const vmType = connection.vmType ?? VMType.UNKNOWN;
|
const vmType = options.connection.vmType ?? VMType.UNKNOWN;
|
||||||
// temporary check to set Z flag or not - to be removed when switching to podman 5
|
// temporary check to set Z flag or not - to be removed when switching to podman 5
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
components.images.map(async image => {
|
components.images.map(async image => {
|
||||||
|
@ -297,28 +305,39 @@ export class ApplicationManager extends Publisher<ApplicationState[]> implements
|
||||||
let envs: string[] = [];
|
let envs: string[] = [];
|
||||||
let healthcheck: HealthConfig | undefined = undefined;
|
let healthcheck: HealthConfig | undefined = undefined;
|
||||||
// if it's a model service we mount the model as a volume
|
// if it's a model service we mount the model as a volume
|
||||||
if (image.modelService) {
|
if (modelPath && isApplicationOptionsWithModelInference(options)) {
|
||||||
const modelName = path.basename(modelPath);
|
if (image.modelService) {
|
||||||
hostConfig = {
|
const modelName = path.basename(modelPath);
|
||||||
Mounts: [
|
hostConfig = {
|
||||||
{
|
Mounts: [
|
||||||
Target: `/${modelName}`,
|
{
|
||||||
Source: modelPath,
|
Target: `/${modelName}`,
|
||||||
Type: 'bind',
|
Source: modelPath,
|
||||||
Mode: vmType === VMType.QEMU ? undefined : 'Z',
|
Type: 'bind',
|
||||||
},
|
Mode: vmType === VMType.QEMU ? undefined : 'Z',
|
||||||
],
|
},
|
||||||
};
|
],
|
||||||
envs = [`MODEL_PATH=/${modelName}`];
|
};
|
||||||
envs.push(...getModelPropertiesForEnvironment(modelInfo));
|
envs = [`MODEL_PATH=/${modelName}`];
|
||||||
} else if (components.inferenceServer) {
|
envs.push(...getModelPropertiesForEnvironment(options.model));
|
||||||
const endPoint = `http://host.containers.internal:${components.inferenceServer.connection.port}`;
|
} else if (components.inferenceServer) {
|
||||||
envs = [`MODEL_ENDPOINT=${endPoint}`];
|
const endPoint = `http://host.containers.internal:${components.inferenceServer.connection.port}`;
|
||||||
} else {
|
|
||||||
const modelService = components.images.find(image => image.modelService);
|
|
||||||
if (modelService && modelService.ports.length > 0) {
|
|
||||||
const endPoint = `http://localhost:${modelService.ports[0]}`;
|
|
||||||
envs = [`MODEL_ENDPOINT=${endPoint}`];
|
envs = [`MODEL_ENDPOINT=${endPoint}`];
|
||||||
|
} else {
|
||||||
|
const modelService = components.images.find(image => image.modelService);
|
||||||
|
if (modelService && modelService.ports.length > 0) {
|
||||||
|
const endPoint = `http://localhost:${modelService.ports[0]}`;
|
||||||
|
envs = [`MODEL_ENDPOINT=${endPoint}`];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (options.dependencies?.llamaStack) {
|
||||||
|
let stack = await this.llamaStackManager.getLlamaStackContainer();
|
||||||
|
if (!stack) {
|
||||||
|
await this.llamaStackManager.createLlamaStackContainer(options.connection, labels ?? {});
|
||||||
|
stack = await this.llamaStackManager.getLlamaStackContainer();
|
||||||
|
}
|
||||||
|
if (stack) {
|
||||||
|
envs = [`MODEL_ENDPOINT=http://host.containers.internal:${stack.port}`];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (image.ports.length > 0) {
|
if (image.ports.length > 0) {
|
||||||
|
@ -346,12 +365,7 @@ export class ApplicationManager extends Publisher<ApplicationState[]> implements
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async createPod(
|
protected async createPod(options: ApplicationOptions, images: RecipeImage[]): Promise<PodInfo> {
|
||||||
connection: ContainerProviderConnection,
|
|
||||||
recipe: Recipe,
|
|
||||||
model: ModelInfo,
|
|
||||||
images: RecipeImage[],
|
|
||||||
): Promise<PodInfo> {
|
|
||||||
// find the exposed port of the sample app so we can open its ports on the new pod
|
// find the exposed port of the sample app so we can open its ports on the new pod
|
||||||
const sampleAppImageInfo = images.find(image => !image.modelService);
|
const sampleAppImageInfo = images.find(image => !image.modelService);
|
||||||
if (!sampleAppImageInfo) {
|
if (!sampleAppImageInfo) {
|
||||||
|
@ -378,9 +392,14 @@ export class ApplicationManager extends Publisher<ApplicationState[]> implements
|
||||||
|
|
||||||
// create new pod
|
// create new pod
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
[POD_LABEL_RECIPE_ID]: recipe.id,
|
[POD_LABEL_RECIPE_ID]: options.recipe.id,
|
||||||
[POD_LABEL_MODEL_ID]: model.id,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isApplicationOptionsWithModelInference(options)) {
|
||||||
|
labels[POD_LABEL_MODEL_ID] = options.model.id;
|
||||||
|
} else {
|
||||||
|
labels[POD_LABEL_MODEL_ID] = '<none>';
|
||||||
|
}
|
||||||
// collecting all modelService ports
|
// collecting all modelService ports
|
||||||
const modelPorts = images
|
const modelPorts = images
|
||||||
.filter(img => img.modelService)
|
.filter(img => img.modelService)
|
||||||
|
@ -398,7 +417,7 @@ export class ApplicationManager extends Publisher<ApplicationState[]> implements
|
||||||
labels[POD_LABEL_APP_PORTS] = appPorts.join(',');
|
labels[POD_LABEL_APP_PORTS] = appPorts.join(',');
|
||||||
}
|
}
|
||||||
const { engineId, Id } = await this.podManager.createPod({
|
const { engineId, Id } = await this.podManager.createPod({
|
||||||
provider: connection,
|
provider: options.connection,
|
||||||
name: getRandomName(`pod-${sampleAppImageInfo.appName}`),
|
name: getRandomName(`pod-${sampleAppImageInfo.appName}`),
|
||||||
portmappings: portmappings,
|
portmappings: portmappings,
|
||||||
labels,
|
labels,
|
||||||
|
@ -635,15 +654,28 @@ export class ApplicationManager extends Publisher<ApplicationState[]> implements
|
||||||
const appPod = await this.getApplicationPod(recipeId, modelId);
|
const appPod = await this.getApplicationPod(recipeId, modelId);
|
||||||
await this.removeApplication(recipeId, modelId);
|
await this.removeApplication(recipeId, modelId);
|
||||||
const recipe = this.catalogManager.getRecipeById(recipeId);
|
const recipe = this.catalogManager.getRecipeById(recipeId);
|
||||||
const model = this.catalogManager.getModelById(appPod.Labels[POD_LABEL_MODEL_ID]);
|
let opts: ApplicationOptions;
|
||||||
|
if (appPod.Labels[POD_LABEL_MODEL_ID] === '<none>') {
|
||||||
|
opts = {
|
||||||
|
connection,
|
||||||
|
recipe,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const model = this.catalogManager.getModelById(appPod.Labels[POD_LABEL_MODEL_ID]);
|
||||||
|
opts = {
|
||||||
|
connection,
|
||||||
|
recipe,
|
||||||
|
model,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// init the recipe
|
// init the recipe
|
||||||
const podInfo = await this.initApplication(connection, recipe, model);
|
const podInfo = await this.initApplication(opts);
|
||||||
|
|
||||||
// start the pod
|
// start the pod
|
||||||
return this.runApplication(podInfo, {
|
return this.runApplication(podInfo, {
|
||||||
'recipe-id': recipe.id,
|
'recipe-id': recipeId,
|
||||||
'model-id': model.id,
|
'model-id': modelId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -96,7 +96,7 @@ beforeEach(async () => {
|
||||||
describe('invalid user catalog', () => {
|
describe('invalid user catalog', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.mocked(promises.readFile).mockResolvedValue('invalid json');
|
vi.mocked(promises.readFile).mockResolvedValue('invalid json');
|
||||||
catalogManager.init();
|
await catalogManager.init();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('expect correct model is returned with valid id', () => {
|
test('expect correct model is returned with valid id', () => {
|
||||||
|
@ -116,7 +116,7 @@ describe('invalid user catalog', () => {
|
||||||
|
|
||||||
test('expect correct model is returned from default catalog with valid id when no user catalog exists', async () => {
|
test('expect correct model is returned from default catalog with valid id when no user catalog exists', async () => {
|
||||||
vi.mocked(existsSync).mockReturnValue(false);
|
vi.mocked(existsSync).mockReturnValue(false);
|
||||||
catalogManager.init();
|
await catalogManager.init();
|
||||||
await vi.waitUntil(() => catalogManager.getRecipes().length > 0);
|
await vi.waitUntil(() => catalogManager.getRecipes().length > 0);
|
||||||
|
|
||||||
const model = catalogManager.getModelById('llama-2-7b-chat.Q5_K_S');
|
const model = catalogManager.getModelById('llama-2-7b-chat.Q5_K_S');
|
||||||
|
@ -132,7 +132,7 @@ test('expect correct model is returned with valid id when the user catalog is va
|
||||||
vi.mocked(existsSync).mockReturnValue(true);
|
vi.mocked(existsSync).mockReturnValue(true);
|
||||||
vi.mocked(promises.readFile).mockResolvedValue(JSON.stringify(userContent));
|
vi.mocked(promises.readFile).mockResolvedValue(JSON.stringify(userContent));
|
||||||
|
|
||||||
catalogManager.init();
|
await catalogManager.init();
|
||||||
await vi.waitUntil(() => catalogManager.getModels().some(model => model.id === 'model1'));
|
await vi.waitUntil(() => catalogManager.getModels().some(model => model.id === 'model1'));
|
||||||
|
|
||||||
const model = catalogManager.getModelById('model1');
|
const model = catalogManager.getModelById('model1');
|
||||||
|
@ -146,7 +146,7 @@ test('expect to call writeFile in addLocalModelsToCatalog with catalog updated',
|
||||||
vi.mocked(existsSync).mockReturnValue(true);
|
vi.mocked(existsSync).mockReturnValue(true);
|
||||||
vi.mocked(promises.readFile).mockResolvedValue(JSON.stringify(userContent));
|
vi.mocked(promises.readFile).mockResolvedValue(JSON.stringify(userContent));
|
||||||
|
|
||||||
catalogManager.init();
|
await catalogManager.init();
|
||||||
await vi.waitUntil(() => catalogManager.getRecipes().length > 0);
|
await vi.waitUntil(() => catalogManager.getRecipes().length > 0);
|
||||||
|
|
||||||
const mtimeDate = new Date('2024-04-03T09:51:15.766Z');
|
const mtimeDate = new Date('2024-04-03T09:51:15.766Z');
|
||||||
|
@ -174,7 +174,7 @@ test('expect to call writeFile in removeLocalModelFromCatalog with catalog updat
|
||||||
vi.mocked(promises.readFile).mockResolvedValue(JSON.stringify(userContent));
|
vi.mocked(promises.readFile).mockResolvedValue(JSON.stringify(userContent));
|
||||||
vi.mocked(path.resolve).mockReturnValue('path');
|
vi.mocked(path.resolve).mockReturnValue('path');
|
||||||
|
|
||||||
catalogManager.init();
|
await catalogManager.init();
|
||||||
await vi.waitUntil(() => catalogManager.getRecipes().length > 0);
|
await vi.waitUntil(() => catalogManager.getRecipes().length > 0);
|
||||||
|
|
||||||
vi.mocked(promises.writeFile).mockResolvedValue();
|
vi.mocked(promises.writeFile).mockResolvedValue();
|
||||||
|
@ -196,7 +196,7 @@ test('catalog should be the combination of user catalog and default catalog', as
|
||||||
vi.mocked(promises.readFile).mockResolvedValue(JSON.stringify(userContent));
|
vi.mocked(promises.readFile).mockResolvedValue(JSON.stringify(userContent));
|
||||||
vi.mocked(path.resolve).mockReturnValue('path');
|
vi.mocked(path.resolve).mockReturnValue('path');
|
||||||
|
|
||||||
catalogManager.init();
|
await catalogManager.init();
|
||||||
await vi.waitUntil(() => catalogManager.getModels().length > userContent.models.length);
|
await vi.waitUntil(() => catalogManager.getModels().length > userContent.models.length);
|
||||||
|
|
||||||
const mtimeDate = new Date('2024-04-03T09:51:15.766Z');
|
const mtimeDate = new Date('2024-04-03T09:51:15.766Z');
|
||||||
|
@ -238,7 +238,7 @@ test('catalog should use user items in favour of default', async () => {
|
||||||
|
|
||||||
vi.mocked(promises.readFile).mockResolvedValue(JSON.stringify(overwriteFullCatalog));
|
vi.mocked(promises.readFile).mockResolvedValue(JSON.stringify(overwriteFullCatalog));
|
||||||
|
|
||||||
catalogManager.init();
|
await catalogManager.init();
|
||||||
await vi.waitUntil(() => catalogManager.getModels().length > 0);
|
await vi.waitUntil(() => catalogManager.getModels().length > 0);
|
||||||
|
|
||||||
const mtimeDate = new Date('2024-04-03T09:51:15.766Z');
|
const mtimeDate = new Date('2024-04-03T09:51:15.766Z');
|
||||||
|
@ -330,7 +330,7 @@ test('filter recipes by language', async () => {
|
||||||
vi.mocked(existsSync).mockReturnValue(true);
|
vi.mocked(existsSync).mockReturnValue(true);
|
||||||
vi.mocked(promises.readFile).mockResolvedValue(JSON.stringify(userContent));
|
vi.mocked(promises.readFile).mockResolvedValue(JSON.stringify(userContent));
|
||||||
|
|
||||||
catalogManager.init();
|
await catalogManager.init();
|
||||||
await vi.waitUntil(() => catalogManager.getModels().some(model => model.id === 'model1'));
|
await vi.waitUntil(() => catalogManager.getModels().some(model => model.id === 'model1'));
|
||||||
const result1 = catalogManager.filterRecipes({
|
const result1 = catalogManager.filterRecipes({
|
||||||
languages: ['lang1'],
|
languages: ['lang1'],
|
||||||
|
@ -375,7 +375,7 @@ test('filter recipes by tool', async () => {
|
||||||
vi.mocked(existsSync).mockReturnValue(true);
|
vi.mocked(existsSync).mockReturnValue(true);
|
||||||
vi.mocked(promises.readFile).mockResolvedValue(JSON.stringify(userContent));
|
vi.mocked(promises.readFile).mockResolvedValue(JSON.stringify(userContent));
|
||||||
|
|
||||||
catalogManager.init();
|
await catalogManager.init();
|
||||||
await vi.waitUntil(() => catalogManager.getModels().some(model => model.id === 'model1'));
|
await vi.waitUntil(() => catalogManager.getModels().some(model => model.id === 'model1'));
|
||||||
|
|
||||||
const result1 = catalogManager.filterRecipes({
|
const result1 = catalogManager.filterRecipes({
|
||||||
|
@ -445,7 +445,7 @@ test('filter recipes by framework', async () => {
|
||||||
vi.mocked(existsSync).mockReturnValue(true);
|
vi.mocked(existsSync).mockReturnValue(true);
|
||||||
vi.mocked(promises.readFile).mockResolvedValue(JSON.stringify(userContent));
|
vi.mocked(promises.readFile).mockResolvedValue(JSON.stringify(userContent));
|
||||||
|
|
||||||
catalogManager.init();
|
await catalogManager.init();
|
||||||
await vi.waitUntil(() => catalogManager.getModels().some(model => model.id === 'model1'));
|
await vi.waitUntil(() => catalogManager.getModels().some(model => model.id === 'model1'));
|
||||||
|
|
||||||
const result1 = catalogManager.filterRecipes({
|
const result1 = catalogManager.filterRecipes({
|
||||||
|
@ -519,7 +519,7 @@ test('filter recipes by language and framework', async () => {
|
||||||
vi.mocked(existsSync).mockReturnValue(true);
|
vi.mocked(existsSync).mockReturnValue(true);
|
||||||
vi.mocked(promises.readFile).mockResolvedValue(JSON.stringify(userContent));
|
vi.mocked(promises.readFile).mockResolvedValue(JSON.stringify(userContent));
|
||||||
|
|
||||||
catalogManager.init();
|
await catalogManager.init();
|
||||||
await vi.waitUntil(() => catalogManager.getModels().some(model => model.id === 'model1'));
|
await vi.waitUntil(() => catalogManager.getModels().some(model => model.id === 'model1'));
|
||||||
|
|
||||||
const result1 = catalogManager.filterRecipes({
|
const result1 = catalogManager.filterRecipes({
|
||||||
|
@ -546,7 +546,7 @@ test('filter recipes by language, tool and framework', async () => {
|
||||||
vi.mocked(existsSync).mockReturnValue(true);
|
vi.mocked(existsSync).mockReturnValue(true);
|
||||||
vi.mocked(promises.readFile).mockResolvedValue(JSON.stringify(userContent));
|
vi.mocked(promises.readFile).mockResolvedValue(JSON.stringify(userContent));
|
||||||
|
|
||||||
catalogManager.init();
|
await catalogManager.init();
|
||||||
await vi.waitUntil(() => catalogManager.getModels().some(model => model.id === 'model1'));
|
await vi.waitUntil(() => catalogManager.getModels().some(model => model.id === 'model1'));
|
||||||
|
|
||||||
const result1 = catalogManager.filterRecipes({
|
const result1 = catalogManager.filterRecipes({
|
||||||
|
@ -567,3 +567,15 @@ test('filter recipes by language, tool and framework', async () => {
|
||||||
tools: [{ name: 'tool1', count: 1 }],
|
tools: [{ name: 'tool1', count: 1 }],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('models are loaded as soon as init is finished when no user catalog', async () => {
|
||||||
|
await catalogManager.init();
|
||||||
|
expect(catalogManager.getModels()).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('models are loaded as soon as init is finished when user catalog exists', async () => {
|
||||||
|
vi.mocked(promises.readFile).mockResolvedValue(JSON.stringify(userContent));
|
||||||
|
vi.mocked(existsSync).mockReturnValue(true);
|
||||||
|
await catalogManager.init();
|
||||||
|
expect(catalogManager.getModels()).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
|
@ -60,16 +60,21 @@ export class CatalogManager extends Publisher<ApplicationCatalog> implements Dis
|
||||||
/**
|
/**
|
||||||
* The init method will start a watcher on the user catalog.json
|
* The init method will start a watcher on the user catalog.json
|
||||||
*/
|
*/
|
||||||
init(): void {
|
async init(): Promise<void> {
|
||||||
// Creating a json watcher
|
return new Promise<void>(resolve => {
|
||||||
this.#jsonWatcher = new JsonWatcher(this.getUserCatalogPath(), {
|
// Creating a json watcher
|
||||||
version: CatalogFormat.CURRENT,
|
this.#jsonWatcher = new JsonWatcher(this.getUserCatalogPath(), {
|
||||||
recipes: [],
|
version: CatalogFormat.CURRENT,
|
||||||
models: [],
|
recipes: [],
|
||||||
categories: [],
|
models: [],
|
||||||
|
categories: [],
|
||||||
|
});
|
||||||
|
this.#jsonWatcher.onContentUpdated(content => {
|
||||||
|
this.onUserCatalogUpdate(content);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
this.#jsonWatcher.init();
|
||||||
});
|
});
|
||||||
this.#jsonWatcher.onContentUpdated(content => this.onUserCatalogUpdate(content));
|
|
||||||
this.#jsonWatcher.init();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadDefaultCatalog(): void {
|
private loadDefaultCatalog(): void {
|
||||||
|
|
|
@ -98,6 +98,15 @@ export class InferenceManager extends Publisher<InferenceServer[]> implements Di
|
||||||
return Array.from(this.#servers.values());
|
return Array.from(this.#servers.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Unique registered Inference provider types
|
||||||
|
*/
|
||||||
|
|
||||||
|
public getRegisteredProviders(): InferenceType[] {
|
||||||
|
const types: InferenceType[] = this.inferenceProviderRegistry.getAll().map(provider => provider.type);
|
||||||
|
return [...new Set(types)];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* return an inference server
|
* return an inference server
|
||||||
* @param containerId the containerId of the inference server
|
* @param containerId the containerId of the inference server
|
||||||
|
|
|
@ -225,7 +225,7 @@ test('getModelsInfo should get models in local directory', async () => {
|
||||||
modelHandlerRegistry,
|
modelHandlerRegistry,
|
||||||
);
|
);
|
||||||
modelHandlerRegistry.register(new URLModelHandler(manager, modelsDir));
|
modelHandlerRegistry.register(new URLModelHandler(manager, modelsDir));
|
||||||
manager.init();
|
await manager.init();
|
||||||
await manager.loadLocalModels();
|
await manager.loadLocalModels();
|
||||||
expect(manager.getModelsInfo()).toEqual([
|
expect(manager.getModelsInfo()).toEqual([
|
||||||
{
|
{
|
||||||
|
@ -277,7 +277,7 @@ test('getModelsInfo should return an empty array if the models folder does not e
|
||||||
modelHandlerRegistry,
|
modelHandlerRegistry,
|
||||||
);
|
);
|
||||||
modelHandlerRegistry.register(new URLModelHandler(manager, modelsDir));
|
modelHandlerRegistry.register(new URLModelHandler(manager, modelsDir));
|
||||||
manager.init();
|
await manager.init();
|
||||||
await manager.getLocalModelsFromDisk();
|
await manager.getLocalModelsFromDisk();
|
||||||
expect(manager.getModelsInfo()).toEqual([]);
|
expect(manager.getModelsInfo()).toEqual([]);
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
|
@ -318,7 +318,7 @@ test('getLocalModelsFromDisk should return undefined Date and size when stat fai
|
||||||
modelHandlerRegistry,
|
modelHandlerRegistry,
|
||||||
);
|
);
|
||||||
modelHandlerRegistry.register(new URLModelHandler(manager, modelsDir));
|
modelHandlerRegistry.register(new URLModelHandler(manager, modelsDir));
|
||||||
manager.init();
|
await manager.init();
|
||||||
await manager.loadLocalModels();
|
await manager.loadLocalModels();
|
||||||
expect(manager.getModelsInfo()).toEqual([
|
expect(manager.getModelsInfo()).toEqual([
|
||||||
{
|
{
|
||||||
|
@ -377,7 +377,7 @@ test('getLocalModelsFromDisk should skip folders containing tmp files', async ()
|
||||||
modelHandlerRegistry,
|
modelHandlerRegistry,
|
||||||
);
|
);
|
||||||
modelHandlerRegistry.register(new URLModelHandler(manager, modelsDir));
|
modelHandlerRegistry.register(new URLModelHandler(manager, modelsDir));
|
||||||
manager.init();
|
await manager.init();
|
||||||
await manager.loadLocalModels();
|
await manager.loadLocalModels();
|
||||||
expect(manager.getModelsInfo()).toEqual([
|
expect(manager.getModelsInfo()).toEqual([
|
||||||
{
|
{
|
||||||
|
@ -417,7 +417,7 @@ test('loadLocalModels should post a message with the message on disk and on cata
|
||||||
modelHandlerRegistry,
|
modelHandlerRegistry,
|
||||||
);
|
);
|
||||||
modelHandlerRegistry.register(new URLModelHandler(manager, modelsDir));
|
modelHandlerRegistry.register(new URLModelHandler(manager, modelsDir));
|
||||||
manager.init();
|
await manager.init();
|
||||||
await manager.loadLocalModels();
|
await manager.loadLocalModels();
|
||||||
expect(rpcExtensionMock.fire).toHaveBeenNthCalledWith(2, MSG_NEW_MODELS_STATE, [
|
expect(rpcExtensionMock.fire).toHaveBeenNthCalledWith(2, MSG_NEW_MODELS_STATE, [
|
||||||
{
|
{
|
||||||
|
@ -464,7 +464,7 @@ test('deleteModel deletes the model folder', async () => {
|
||||||
modelHandlerRegistry,
|
modelHandlerRegistry,
|
||||||
);
|
);
|
||||||
modelHandlerRegistry.register(new URLModelHandler(manager, modelsDir));
|
modelHandlerRegistry.register(new URLModelHandler(manager, modelsDir));
|
||||||
manager.init();
|
await manager.init();
|
||||||
await manager.loadLocalModels();
|
await manager.loadLocalModels();
|
||||||
await manager.deleteModel('model-id-1');
|
await manager.deleteModel('model-id-1');
|
||||||
// check that the model's folder is removed from disk
|
// check that the model's folder is removed from disk
|
||||||
|
@ -525,7 +525,7 @@ describe('deleting models', () => {
|
||||||
modelHandlerRegistry,
|
modelHandlerRegistry,
|
||||||
);
|
);
|
||||||
modelHandlerRegistry.register(new URLModelHandler(manager, modelsDir));
|
modelHandlerRegistry.register(new URLModelHandler(manager, modelsDir));
|
||||||
manager.init();
|
await manager.init();
|
||||||
await manager.loadLocalModels();
|
await manager.loadLocalModels();
|
||||||
await manager.deleteModel('model-id-1');
|
await manager.deleteModel('model-id-1');
|
||||||
// check that the model's folder is removed from disk
|
// check that the model's folder is removed from disk
|
||||||
|
@ -899,7 +899,7 @@ describe('getModelMetadata', () => {
|
||||||
modelHandlerRegistry,
|
modelHandlerRegistry,
|
||||||
);
|
);
|
||||||
|
|
||||||
manager.init();
|
await manager.init();
|
||||||
|
|
||||||
const fakeMetadata: Record<string, string> = {
|
const fakeMetadata: Record<string, string> = {
|
||||||
hello: 'world',
|
hello: 'world',
|
||||||
|
@ -939,7 +939,7 @@ describe('getModelMetadata', () => {
|
||||||
modelHandlerRegistry,
|
modelHandlerRegistry,
|
||||||
);
|
);
|
||||||
|
|
||||||
manager.init();
|
await manager.init();
|
||||||
|
|
||||||
const fakeMetadata: Record<string, string> = {
|
const fakeMetadata: Record<string, string> = {
|
||||||
hello: 'world',
|
hello: 'world',
|
||||||
|
@ -995,7 +995,7 @@ describe('uploadModelToPodmanMachine', () => {
|
||||||
modelHandlerRegistry,
|
modelHandlerRegistry,
|
||||||
);
|
);
|
||||||
|
|
||||||
manager.init();
|
await manager.init();
|
||||||
const result = await manager.uploadModelToPodmanMachine(connectionMock, modelMock);
|
const result = await manager.uploadModelToPodmanMachine(connectionMock, modelMock);
|
||||||
expect(result).toBe('uploader-result');
|
expect(result).toBe('uploader-result');
|
||||||
expect(performMock).toHaveBeenCalledWith(modelMock.id);
|
expect(performMock).toHaveBeenCalledWith(modelMock.id);
|
||||||
|
@ -1028,7 +1028,7 @@ describe('uploadModelToPodmanMachine', () => {
|
||||||
modelHandlerRegistry,
|
modelHandlerRegistry,
|
||||||
);
|
);
|
||||||
|
|
||||||
manager.init();
|
await manager.init();
|
||||||
await manager.uploadModelToPodmanMachine(connectionMock, modelMock);
|
await manager.uploadModelToPodmanMachine(connectionMock, modelMock);
|
||||||
expect(Uploader).not.toHaveBeenCalled();
|
expect(Uploader).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
|
@ -63,7 +63,7 @@ export class ModelsManager implements Disposable {
|
||||||
this.modelHandlerRegistry.getAll().forEach(handler => handler.onUpdate(this.loadLocalModels));
|
this.modelHandlerRegistry.getAll().forEach(handler => handler.onUpdate(this.loadLocalModels));
|
||||||
}
|
}
|
||||||
|
|
||||||
init(): void {
|
async init(): Promise<void> {
|
||||||
const disposable = this.catalogManager.onUpdate(() => {
|
const disposable = this.catalogManager.onUpdate(() => {
|
||||||
this.loadLocalModels().catch((err: unknown) => {
|
this.loadLocalModels().catch((err: unknown) => {
|
||||||
console.error(`Something went wrong when loading local models`, err);
|
console.error(`Something went wrong when loading local models`, err);
|
||||||
|
@ -71,9 +71,11 @@ export class ModelsManager implements Disposable {
|
||||||
});
|
});
|
||||||
this.#disposables.push(disposable);
|
this.#disposables.push(disposable);
|
||||||
|
|
||||||
this.loadLocalModels().catch((err: unknown) => {
|
try {
|
||||||
|
await this.loadLocalModels();
|
||||||
|
} catch (err: unknown) {
|
||||||
console.error('Something went wrong while trying to load local models', err);
|
console.error('Something went wrong while trying to load local models', err);
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
|
|
|
@ -30,6 +30,7 @@ import { goarch } from '../../utils/arch';
|
||||||
import { VMType } from '@shared/models/IPodman';
|
import { VMType } from '@shared/models/IPodman';
|
||||||
import type { InferenceManager } from '../inference/inferenceManager';
|
import type { InferenceManager } from '../inference/inferenceManager';
|
||||||
import type { ModelInfo } from '@shared/models/IModelInfo';
|
import type { ModelInfo } from '@shared/models/IModelInfo';
|
||||||
|
import type { ApplicationOptions } from '../../models/ApplicationOptions';
|
||||||
|
|
||||||
const taskRegistryMock = {
|
const taskRegistryMock = {
|
||||||
createTask: vi.fn(),
|
createTask: vi.fn(),
|
||||||
|
@ -184,21 +185,34 @@ describe('cloneRecipe', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('buildRecipe', () => {
|
describe.each([true, false])('buildRecipe, with model is %o', withModel => {
|
||||||
|
let applicationOptions: ApplicationOptions;
|
||||||
|
beforeEach(() => {
|
||||||
|
applicationOptions = withModel
|
||||||
|
? {
|
||||||
|
connection: connectionMock,
|
||||||
|
recipe: recipeMock,
|
||||||
|
model: modelInfoMock,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
connection: connectionMock,
|
||||||
|
recipe: recipeMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
test('error in build propagate it', async () => {
|
test('error in build propagate it', async () => {
|
||||||
vi.mocked(builderManagerMock.build).mockRejectedValue(new Error('build error'));
|
vi.mocked(builderManagerMock.build).mockRejectedValue(new Error('build error'));
|
||||||
|
|
||||||
const manager = await getInitializedRecipeManager();
|
const manager = await getInitializedRecipeManager();
|
||||||
|
|
||||||
await expect(() => {
|
await expect(() => {
|
||||||
return manager.buildRecipe(connectionMock, recipeMock, modelInfoMock);
|
return manager.buildRecipe(applicationOptions);
|
||||||
}).rejects.toThrowError('build error');
|
}).rejects.toThrowError('build error');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('labels should be propagated', async () => {
|
test('labels should be propagated', async () => {
|
||||||
const manager = await getInitializedRecipeManager();
|
const manager = await getInitializedRecipeManager();
|
||||||
|
|
||||||
await manager.buildRecipe(connectionMock, recipeMock, modelInfoMock, {
|
await manager.buildRecipe(applicationOptions, {
|
||||||
'test-label': 'test-value',
|
'test-label': 'test-value',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -26,12 +26,12 @@ import { parseYamlFile } from '../../models/AIConfig';
|
||||||
import { existsSync, statSync } from 'node:fs';
|
import { existsSync, statSync } from 'node:fs';
|
||||||
import { goarch } from '../../utils/arch';
|
import { goarch } from '../../utils/arch';
|
||||||
import type { BuilderManager } from './BuilderManager';
|
import type { BuilderManager } from './BuilderManager';
|
||||||
import type { ContainerProviderConnection, Disposable } from '@podman-desktop/api';
|
import type { Disposable } from '@podman-desktop/api';
|
||||||
import { CONFIG_FILENAME } from '../../utils/RecipeConstants';
|
import { CONFIG_FILENAME } from '../../utils/RecipeConstants';
|
||||||
import type { InferenceManager } from '../inference/inferenceManager';
|
import type { InferenceManager } from '../inference/inferenceManager';
|
||||||
import type { ModelInfo } from '@shared/models/IModelInfo';
|
|
||||||
import { withDefaultConfiguration } from '../../utils/inferenceUtils';
|
import { withDefaultConfiguration } from '../../utils/inferenceUtils';
|
||||||
import type { InferenceServer } from '@shared/models/IInference';
|
import type { InferenceServer } from '@shared/models/IInference';
|
||||||
|
import { type ApplicationOptions, isApplicationOptionsWithModelInference } from '../../models/ApplicationOptions';
|
||||||
|
|
||||||
export interface AIContainers {
|
export interface AIContainers {
|
||||||
aiConfigFile: AIConfigFile;
|
aiConfigFile: AIConfigFile;
|
||||||
|
@ -96,73 +96,70 @@ export class RecipeManager implements Disposable {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async buildRecipe(
|
public async buildRecipe(options: ApplicationOptions, labels?: { [key: string]: string }): Promise<RecipeComponents> {
|
||||||
connection: ContainerProviderConnection,
|
const localFolder = path.join(this.appUserDirectory, options.recipe.id);
|
||||||
recipe: Recipe,
|
|
||||||
model: ModelInfo,
|
|
||||||
labels?: { [key: string]: string },
|
|
||||||
): Promise<RecipeComponents> {
|
|
||||||
const localFolder = path.join(this.appUserDirectory, recipe.id);
|
|
||||||
|
|
||||||
let inferenceServer: InferenceServer | undefined;
|
let inferenceServer: InferenceServer | undefined;
|
||||||
// if the recipe has a defined backend, we gives priority to using an inference server
|
if (isApplicationOptionsWithModelInference(options)) {
|
||||||
if (recipe.backend && recipe.backend === model.backend) {
|
// if the recipe has a defined backend, we gives priority to using an inference server
|
||||||
let task: Task | undefined;
|
if (options.recipe.backend && options.recipe.backend === options.model.backend) {
|
||||||
try {
|
let task: Task | undefined;
|
||||||
inferenceServer = this.inferenceManager.findServerByModel(model);
|
try {
|
||||||
task = this.taskRegistry.createTask('Starting Inference server', 'loading', labels);
|
inferenceServer = this.inferenceManager.findServerByModel(options.model);
|
||||||
if (!inferenceServer) {
|
task = this.taskRegistry.createTask('Starting Inference server', 'loading', labels);
|
||||||
const inferenceContainerId = await this.inferenceManager.createInferenceServer(
|
if (!inferenceServer) {
|
||||||
await withDefaultConfiguration({
|
const inferenceContainerId = await this.inferenceManager.createInferenceServer(
|
||||||
modelsInfo: [model],
|
await withDefaultConfiguration({
|
||||||
}),
|
modelsInfo: [options.model],
|
||||||
);
|
}),
|
||||||
inferenceServer = this.inferenceManager.get(inferenceContainerId);
|
);
|
||||||
this.taskRegistry.updateTask({
|
inferenceServer = this.inferenceManager.get(inferenceContainerId);
|
||||||
...task,
|
this.taskRegistry.updateTask({
|
||||||
labels: {
|
...task,
|
||||||
...task.labels,
|
labels: {
|
||||||
containerId: inferenceContainerId,
|
...task.labels,
|
||||||
},
|
containerId: inferenceContainerId,
|
||||||
});
|
},
|
||||||
} else if (inferenceServer.status === 'stopped') {
|
});
|
||||||
await this.inferenceManager.startInferenceServer(inferenceServer.container.containerId);
|
} else if (inferenceServer.status === 'stopped') {
|
||||||
}
|
await this.inferenceManager.startInferenceServer(inferenceServer.container.containerId);
|
||||||
task.state = 'success';
|
}
|
||||||
} catch (e) {
|
task.state = 'success';
|
||||||
// we only skip the task update if the error is that we do not support this backend.
|
} catch (e) {
|
||||||
// If so, we build the image for the model service
|
// we only skip the task update if the error is that we do not support this backend.
|
||||||
if (task && String(e) !== 'no enabled provider could be found.') {
|
// If so, we build the image for the model service
|
||||||
task.state = 'error';
|
if (task && String(e) !== 'no enabled provider could be found.') {
|
||||||
task.error = `Something went wrong while starting the inference server: ${String(e)}`;
|
task.state = 'error';
|
||||||
throw e;
|
task.error = `Something went wrong while starting the inference server: ${String(e)}`;
|
||||||
}
|
throw e;
|
||||||
} finally {
|
}
|
||||||
if (task) {
|
} finally {
|
||||||
this.taskRegistry.updateTask(task);
|
if (task) {
|
||||||
|
this.taskRegistry.updateTask(task);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// load and parse the recipe configuration file and filter containers based on architecture
|
// load and parse the recipe configuration file and filter containers based on architecture
|
||||||
const configAndFilteredContainers = this.getConfigAndFilterContainers(
|
const configAndFilteredContainers = this.getConfigAndFilterContainers(
|
||||||
recipe.basedir,
|
options.recipe.basedir,
|
||||||
localFolder,
|
localFolder,
|
||||||
!!inferenceServer,
|
!!inferenceServer,
|
||||||
{
|
{
|
||||||
...labels,
|
...labels,
|
||||||
'recipe-id': recipe.id,
|
'recipe-id': options.recipe.id,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const images = await this.builderManager.build(
|
const images = await this.builderManager.build(
|
||||||
connection,
|
options.connection,
|
||||||
recipe,
|
options.recipe,
|
||||||
configAndFilteredContainers.containers,
|
configAndFilteredContainers.containers,
|
||||||
configAndFilteredContainers.aiConfigFile.path,
|
configAndFilteredContainers.aiConfigFile.path,
|
||||||
{
|
{
|
||||||
...labels,
|
...labels,
|
||||||
'recipe-id': recipe.id,
|
'recipe-id': options.recipe.id,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,39 @@
|
||||||
|
/**********************************************************************
|
||||||
|
* Copyright (C) 2025 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 type { ContainerProviderConnection } from '@podman-desktop/api';
|
||||||
|
import type { ModelInfo } from '@shared/models/IModelInfo';
|
||||||
|
import type { Recipe, RecipeDependencies } from '@shared/models/IRecipe';
|
||||||
|
|
||||||
|
export type ApplicationOptions = ApplicationOptionsDefault | ApplicationOptionsWithModelInference;
|
||||||
|
|
||||||
|
export interface ApplicationOptionsDefault {
|
||||||
|
connection: ContainerProviderConnection;
|
||||||
|
recipe: Recipe;
|
||||||
|
dependencies?: RecipeDependencies;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ApplicationOptionsWithModelInference = ApplicationOptionsDefault & {
|
||||||
|
model: ModelInfo;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isApplicationOptionsWithModelInference(
|
||||||
|
options: ApplicationOptions,
|
||||||
|
): options is ApplicationOptionsWithModelInference {
|
||||||
|
return 'model' in options;
|
||||||
|
}
|
|
@ -170,36 +170,42 @@ beforeEach(async () => {
|
||||||
} as unknown as EventEmitter<unknown>);
|
} as unknown as EventEmitter<unknown>);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('expect requestPullApplication to provide a tracking id', async () => {
|
describe.each([true, false])('with model is %o', withModel => {
|
||||||
const connectionMock = {
|
test('expect requestPullApplication to provide a tracking id', async () => {
|
||||||
name: 'Podman machine',
|
const connectionMock = {
|
||||||
} as unknown as ContainerProviderConnection;
|
name: 'Podman machine',
|
||||||
vi.mocked(podmanConnectionMock.findRunningContainerProviderConnection).mockReturnValue(connectionMock);
|
} as unknown as ContainerProviderConnection;
|
||||||
vi.spyOn(catalogManager, 'getRecipes').mockReturnValue([
|
vi.mocked(podmanConnectionMock.findRunningContainerProviderConnection).mockReturnValue(connectionMock);
|
||||||
{
|
vi.spyOn(catalogManager, 'getRecipes').mockReturnValue([
|
||||||
id: 'recipe 1',
|
{
|
||||||
} as unknown as Recipe,
|
id: 'recipe 1',
|
||||||
]);
|
} as unknown as Recipe,
|
||||||
vi.spyOn(catalogManager, 'getModelById').mockReturnValue({
|
]);
|
||||||
id: 'model 1',
|
vi.spyOn(catalogManager, 'getModelById').mockReturnValue({
|
||||||
} as unknown as ModelInfo);
|
|
||||||
|
|
||||||
vi.mocked(applicationManager.requestPullApplication).mockResolvedValue('dummy-tracker');
|
|
||||||
|
|
||||||
const trackingId = await studioApiImpl.requestPullApplication({
|
|
||||||
modelId: 'model1',
|
|
||||||
recipeId: 'recipe 1',
|
|
||||||
});
|
|
||||||
expect(applicationManager.requestPullApplication).toHaveBeenCalledWith(
|
|
||||||
connectionMock,
|
|
||||||
expect.objectContaining({
|
|
||||||
id: 'recipe 1',
|
|
||||||
}),
|
|
||||||
expect.objectContaining({
|
|
||||||
id: 'model 1',
|
id: 'model 1',
|
||||||
}),
|
} as unknown as ModelInfo);
|
||||||
);
|
|
||||||
expect(trackingId).toBe('dummy-tracker');
|
vi.mocked(applicationManager.requestPullApplication).mockResolvedValue('dummy-tracker');
|
||||||
|
|
||||||
|
const recipeId = 'recipe 1';
|
||||||
|
let modelId: string | undefined;
|
||||||
|
if (withModel) {
|
||||||
|
modelId = 'model1';
|
||||||
|
}
|
||||||
|
const trackingId = await studioApiImpl.requestPullApplication(withModel ? { recipeId, modelId } : { recipeId });
|
||||||
|
expect(applicationManager.requestPullApplication).toHaveBeenCalledWith({
|
||||||
|
connection: connectionMock,
|
||||||
|
recipe: expect.objectContaining({
|
||||||
|
id: 'recipe 1',
|
||||||
|
}),
|
||||||
|
model: withModel
|
||||||
|
? expect.objectContaining({
|
||||||
|
id: 'model 1',
|
||||||
|
})
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
expect(trackingId).toBe('dummy-tracker');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('requestRemoveApplication should ask confirmation', async () => {
|
test('requestRemoveApplication should ask confirmation', async () => {
|
||||||
|
|
|
@ -30,7 +30,7 @@ import type { TaskRegistry } from './registries/TaskRegistry';
|
||||||
import type { LocalRepository } from '@shared/models/ILocalRepository';
|
import type { LocalRepository } from '@shared/models/ILocalRepository';
|
||||||
import type { LocalRepositoryRegistry } from './registries/LocalRepositoryRegistry';
|
import type { LocalRepositoryRegistry } from './registries/LocalRepositoryRegistry';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import type { InferenceServer } from '@shared/models/IInference';
|
import type { InferenceServer, InferenceType } from '@shared/models/IInference';
|
||||||
import type { CreationInferenceServerOptions } from '@shared/models/InferenceServerConfig';
|
import type { CreationInferenceServerOptions } from '@shared/models/InferenceServerConfig';
|
||||||
import type { InferenceManager } from './managers/inference/inferenceManager';
|
import type { InferenceManager } from './managers/inference/inferenceManager';
|
||||||
import type { Conversation } from '@shared/models/IPlaygroundMessage';
|
import type { Conversation } from '@shared/models/IPlaygroundMessage';
|
||||||
|
@ -53,10 +53,11 @@ import type { ExtensionConfiguration } from '@shared/models/IExtensionConfigurat
|
||||||
import type { ConfigurationRegistry } from './registries/ConfigurationRegistry';
|
import type { ConfigurationRegistry } from './registries/ConfigurationRegistry';
|
||||||
import type { RecipeManager } from './managers/recipes/RecipeManager';
|
import type { RecipeManager } from './managers/recipes/RecipeManager';
|
||||||
import type { PodmanConnection } from './managers/podmanConnection';
|
import type { PodmanConnection } from './managers/podmanConnection';
|
||||||
import type { RecipePullOptions } from '@shared/models/IRecipe';
|
import { isRecipePullOptionsWithModelInference, type RecipePullOptions } from '@shared/models/IRecipe';
|
||||||
import type { ContainerProviderConnection } from '@podman-desktop/api';
|
import type { ContainerProviderConnection } from '@podman-desktop/api';
|
||||||
import type { NavigationRegistry } from './registries/NavigationRegistry';
|
import type { NavigationRegistry } from './registries/NavigationRegistry';
|
||||||
import type { FilterRecipesResult, RecipeFilters } from '@shared/models/FilterRecipesResult';
|
import type { FilterRecipesResult, RecipeFilters } from '@shared/models/FilterRecipesResult';
|
||||||
|
import type { ApplicationOptions } from './models/ApplicationOptions';
|
||||||
|
|
||||||
interface PortQuickPickItem extends podmanDesktopApi.QuickPickItem {
|
interface PortQuickPickItem extends podmanDesktopApi.QuickPickItem {
|
||||||
port: number;
|
port: number;
|
||||||
|
@ -143,6 +144,10 @@ export class StudioApiImpl implements StudioAPI {
|
||||||
return this.inferenceManager.getServers();
|
return this.inferenceManager.getServers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getRegisteredProviders(): Promise<InferenceType[]> {
|
||||||
|
return this.inferenceManager.getRegisteredProviders();
|
||||||
|
}
|
||||||
|
|
||||||
async requestDeleteInferenceServer(...containerIds: string[]): Promise<void> {
|
async requestDeleteInferenceServer(...containerIds: string[]): Promise<void> {
|
||||||
// Do not wait on the promise as the api would probably timeout before the user answer.
|
// Do not wait on the promise as the api would probably timeout before the user answer.
|
||||||
if (containerIds.length === 0) throw new Error('At least one container id should be provided.');
|
if (containerIds.length === 0) throw new Error('At least one container id should be provided.');
|
||||||
|
@ -229,8 +234,6 @@ export class StudioApiImpl implements StudioAPI {
|
||||||
const recipe = this.catalogManager.getRecipes().find(recipe => recipe.id === options.recipeId);
|
const recipe = this.catalogManager.getRecipes().find(recipe => recipe.id === options.recipeId);
|
||||||
if (!recipe) throw new Error(`recipe with if ${options.recipeId} not found`);
|
if (!recipe) throw new Error(`recipe with if ${options.recipeId} not found`);
|
||||||
|
|
||||||
const model = this.catalogManager.getModelById(options.modelId);
|
|
||||||
|
|
||||||
let connection: ContainerProviderConnection | undefined = undefined;
|
let connection: ContainerProviderConnection | undefined = undefined;
|
||||||
if (options.connection) {
|
if (options.connection) {
|
||||||
connection = this.podmanConnection.getContainerProviderConnection(options.connection);
|
connection = this.podmanConnection.getContainerProviderConnection(options.connection);
|
||||||
|
@ -240,7 +243,25 @@ export class StudioApiImpl implements StudioAPI {
|
||||||
|
|
||||||
if (!connection) throw new Error('no running container provider connection found.');
|
if (!connection) throw new Error('no running container provider connection found.');
|
||||||
|
|
||||||
return this.applicationManager.requestPullApplication(connection, recipe, model);
|
let model: ModelInfo | undefined;
|
||||||
|
let opts: ApplicationOptions;
|
||||||
|
if (isRecipePullOptionsWithModelInference(options)) {
|
||||||
|
model = this.catalogManager.getModelById(options.modelId);
|
||||||
|
opts = {
|
||||||
|
connection,
|
||||||
|
recipe,
|
||||||
|
dependencies: options.dependencies,
|
||||||
|
model,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
opts = {
|
||||||
|
connection,
|
||||||
|
recipe,
|
||||||
|
dependencies: options.dependencies,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.applicationManager.requestPullApplication(opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getModelsInfo(): Promise<ModelInfo[]> {
|
async getModelsInfo(): Promise<ModelInfo[]> {
|
||||||
|
|
|
@ -21,10 +21,12 @@
|
||||||
import { afterEach, beforeEach, expect, test, vi, describe, type MockInstance } from 'vitest';
|
import { afterEach, beforeEach, expect, test, vi, describe, type MockInstance } from 'vitest';
|
||||||
import { Studio } from './studio';
|
import { Studio } from './studio';
|
||||||
import { type ExtensionContext, EventEmitter, version } from '@podman-desktop/api';
|
import { type ExtensionContext, EventEmitter, version } from '@podman-desktop/api';
|
||||||
|
import { CatalogManager } from './managers/catalogManager';
|
||||||
|
|
||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
|
|
||||||
vi.mock('./managers/modelsManager');
|
vi.mock('./managers/modelsManager');
|
||||||
|
vi.mock('./managers/catalogManager');
|
||||||
|
|
||||||
const mockedExtensionContext = {
|
const mockedExtensionContext = {
|
||||||
subscriptions: [],
|
subscriptions: [],
|
||||||
|
@ -124,6 +126,12 @@ beforeEach(() => {
|
||||||
} as unknown as EventEmitter<unknown>);
|
} as unknown as EventEmitter<unknown>);
|
||||||
|
|
||||||
mocks.postMessage.mockResolvedValue(undefined);
|
mocks.postMessage.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
vi.mocked(CatalogManager).mockReturnValue({
|
||||||
|
onUpdate: vi.fn(),
|
||||||
|
init: vi.fn(),
|
||||||
|
getRecipes: vi.fn().mockReturnValue([]),
|
||||||
|
} as unknown as CatalogManager);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
|
@ -212,7 +212,7 @@ export class Studio {
|
||||||
* Create catalog manager, responsible for loading the catalog files and watching for changes
|
* Create catalog manager, responsible for loading the catalog files and watching for changes
|
||||||
*/
|
*/
|
||||||
this.#catalogManager = new CatalogManager(this.#rpcExtension, appUserDirectory);
|
this.#catalogManager = new CatalogManager(this.#rpcExtension, appUserDirectory);
|
||||||
this.#catalogManager.init();
|
await this.#catalogManager.init();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The builder manager is handling the building tasks, create corresponding tasks
|
* The builder manager is handling the building tasks, create corresponding tasks
|
||||||
|
@ -251,7 +251,7 @@ export class Studio {
|
||||||
const hfModelHandler = new HuggingFaceModelHandler(this.#modelsManager);
|
const hfModelHandler = new HuggingFaceModelHandler(this.#modelsManager);
|
||||||
this.#extensionContext.subscriptions.push(hfModelHandler);
|
this.#extensionContext.subscriptions.push(hfModelHandler);
|
||||||
this.#extensionContext.subscriptions.push(modelHandlerRegistry.register(hfModelHandler));
|
this.#extensionContext.subscriptions.push(modelHandlerRegistry.register(hfModelHandler));
|
||||||
this.#modelsManager.init();
|
await this.#modelsManager.init();
|
||||||
this.#extensionContext.subscriptions.push(this.#modelsManager);
|
this.#extensionContext.subscriptions.push(this.#modelsManager);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -358,6 +358,7 @@ export class Studio {
|
||||||
this.#telemetry,
|
this.#telemetry,
|
||||||
this.#podManager,
|
this.#podManager,
|
||||||
this.#recipeManager,
|
this.#recipeManager,
|
||||||
|
this.#llamaStackManager,
|
||||||
);
|
);
|
||||||
this.#applicationManager.init();
|
this.#applicationManager.init();
|
||||||
this.#extensionContext.subscriptions.push(this.#applicationManager);
|
this.#extensionContext.subscriptions.push(this.#applicationManager);
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "frontend-app",
|
"name": "frontend-app",
|
||||||
"displayName": "UI for AI Lab",
|
"displayName": "UI for AI Lab",
|
||||||
"version": "1.7.0-next",
|
"version": "1.9.0-next",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
@ -18,32 +18,32 @@
|
||||||
"@fortawesome/free-regular-svg-icons": "^6.7.2",
|
"@fortawesome/free-regular-svg-icons": "^6.7.2",
|
||||||
"@podman-desktop/ui-svelte": "1.16.0-202501131429-9076680fda2",
|
"@podman-desktop/ui-svelte": "1.16.0-202501131429-9076680fda2",
|
||||||
"tinro": "^0.6.12",
|
"tinro": "^0.6.12",
|
||||||
"filesize": "^10.1.6",
|
"filesize": "^11.0.2",
|
||||||
"humanize-duration": "^3.32.2",
|
"humanize-duration": "^3.32.2",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"semver": "^7.7.2"
|
"semver": "^7.7.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "5.0.3",
|
"@sveltejs/vite-plugin-svelte": "5.1.0",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@tailwindcss/vite": "^4.1.7",
|
"@tailwindcss/vite": "^4.1.12",
|
||||||
"@testing-library/dom": "^10.4.0",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.8.0",
|
||||||
"@testing-library/svelte": "^5.2.8",
|
"@testing-library/svelte": "^5.2.8",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@tsconfig/svelte": "^5.0.4",
|
"@tsconfig/svelte": "^5.0.5",
|
||||||
"@types/humanize-duration": "^3.27.4",
|
"@types/humanize-duration": "^3.27.4",
|
||||||
"@typescript-eslint/eslint-plugin": "8.32.1",
|
"@typescript-eslint/eslint-plugin": "8.40.0",
|
||||||
"jsdom": "^26.1.0",
|
"jsdom": "^26.1.0",
|
||||||
"monaco-editor": "^0.52.2",
|
"monaco-editor": "^0.52.2",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.6",
|
||||||
"postcss-load-config": "^6.0.1",
|
"postcss-load-config": "^6.0.1",
|
||||||
"svelte": "5.31.0",
|
"svelte": "5.38.2",
|
||||||
"svelte-fa": "^4.0.4",
|
"svelte-fa": "^4.0.4",
|
||||||
"svelte-select": "^5.8.3",
|
"svelte-select": "^5.8.3",
|
||||||
"svelte-markdown": "^0.4.1",
|
"svelte-markdown": "^0.4.1",
|
||||||
"svelte-preprocess": "^6.0.3",
|
"svelte-preprocess": "^6.0.3",
|
||||||
"tailwindcss": "^4.1.7",
|
"tailwindcss": "^4.1.12",
|
||||||
"vitest": "^3.0.5"
|
"vitest": "^3.0.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -117,7 +117,7 @@ function toggleExpanded(): void {
|
||||||
class="w-full flex flex-row gap-2 py-2"
|
class="w-full flex flex-row gap-2 py-2"
|
||||||
class:overflow-hidden={!expanded}
|
class:overflow-hidden={!expanded}
|
||||||
class:flex-wrap={expanded}>
|
class:flex-wrap={expanded}>
|
||||||
{#each TAGS as tag, i (tag)}
|
{#each TAGS as tag, i (i)}
|
||||||
<div bind:this={divTags[i]}>
|
<div bind:this={divTags[i]}>
|
||||||
<Badge class="{getBGColor(tag)} {getTextColor(tag)}" content={updateContent(tag)} />
|
<Badge class="{getBGColor(tag)} {getTextColor(tag)}" content={updateContent(tag)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -21,8 +21,8 @@ import { gte } from 'semver';
|
||||||
|
|
||||||
const USE_CASES = ['natural-language-processing', 'audio', 'computer-vision'];
|
const USE_CASES = ['natural-language-processing', 'audio', 'computer-vision'];
|
||||||
const LANGUAGES = ['java', 'javascript', 'python'];
|
const LANGUAGES = ['java', 'javascript', 'python'];
|
||||||
export const FRAMEWORKS = ['langchain', 'langchain4j', 'quarkus', 'react', 'streamlit', 'vectordb'];
|
export const FRAMEWORKS = ['langchain', 'langchain4j', 'quarkus', 'react', 'streamlit', 'vectordb', 'llama-stack-sdk'];
|
||||||
export const TOOLS = ['none', 'llama-cpp', 'whisper-cpp'];
|
export const TOOLS = ['none', 'llama-cpp', 'whisper-cpp', 'llama-stack'];
|
||||||
|
|
||||||
// Defaulting to Podman Desktop min version we need to run
|
// Defaulting to Podman Desktop min version we need to run
|
||||||
let version: string = '1.8.0';
|
let version: string = '1.8.0';
|
||||||
|
|
|
@ -0,0 +1,99 @@
|
||||||
|
/**********************************************************************
|
||||||
|
* Copyright (C) 2025 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 '@testing-library/jest-dom/vitest';
|
||||||
|
import { beforeEach, vi, test, expect } from 'vitest';
|
||||||
|
import { render, fireEvent, within } from '@testing-library/svelte';
|
||||||
|
import InferenceRuntimeSelect from '/@/lib/select/InferenceRuntimeSelect.svelte';
|
||||||
|
import { InferenceType } from '@shared/models/IInference';
|
||||||
|
|
||||||
|
const providers: InferenceType[] = [InferenceType.LLAMA_CPP, InferenceType.OPENVINO, InferenceType.WHISPER_CPP];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// mock scrollIntoView
|
||||||
|
window.HTMLElement.prototype.scrollIntoView = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Lists all runtime options', async () => {
|
||||||
|
const { container } = render(InferenceRuntimeSelect, {
|
||||||
|
value: undefined,
|
||||||
|
providers,
|
||||||
|
disabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = within(container).getByLabelText('Select Inference Runtime');
|
||||||
|
await fireEvent.pointerUp(input);
|
||||||
|
|
||||||
|
const items = container.querySelectorAll('div[class~="list-item"]');
|
||||||
|
const expectedOptions = providers;
|
||||||
|
|
||||||
|
expect(items.length).toBe(expectedOptions.length);
|
||||||
|
|
||||||
|
expectedOptions.forEach((option, i) => {
|
||||||
|
expect(items[i]).toHaveTextContent(option);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Selected value should be visible', async () => {
|
||||||
|
const { container } = render(InferenceRuntimeSelect, {
|
||||||
|
value: undefined,
|
||||||
|
providers,
|
||||||
|
disabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = within(container).getByLabelText('Select Inference Runtime');
|
||||||
|
await fireEvent.pointerUp(input);
|
||||||
|
|
||||||
|
const items = container.querySelectorAll('div[class~="list-item"]');
|
||||||
|
const expectedOptions = providers;
|
||||||
|
|
||||||
|
await fireEvent.click(items[0]);
|
||||||
|
|
||||||
|
const valueContainer = container.querySelector('.value-container');
|
||||||
|
if (!(valueContainer instanceof HTMLElement)) throw new Error('Missing value container');
|
||||||
|
|
||||||
|
const selectedLabel = within(valueContainer).getByText(expectedOptions[0]);
|
||||||
|
expect(selectedLabel).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Exclude specific runtime from list', async () => {
|
||||||
|
const excluded = [InferenceType.WHISPER_CPP, InferenceType.OPENVINO];
|
||||||
|
|
||||||
|
const { container } = render(InferenceRuntimeSelect, {
|
||||||
|
value: undefined,
|
||||||
|
providers,
|
||||||
|
disabled: false,
|
||||||
|
exclude: excluded,
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = within(container).getByLabelText('Select Inference Runtime');
|
||||||
|
await fireEvent.pointerUp(input);
|
||||||
|
|
||||||
|
const items = container.querySelectorAll('div[class~="list-item"]');
|
||||||
|
const itemTexts = Array.from(items).map(item => item.textContent?.trim());
|
||||||
|
|
||||||
|
excluded.forEach(excludedType => {
|
||||||
|
expect(itemTexts).not.toContain(excludedType);
|
||||||
|
});
|
||||||
|
|
||||||
|
const expected = providers.filter(type => !excluded.includes(type));
|
||||||
|
|
||||||
|
expected.forEach(included => {
|
||||||
|
expect(itemTexts).toContain(included);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,34 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Select from '/@/lib/select/Select.svelte';
|
||||||
|
import type { InferenceType } from '@shared/models/IInference';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
disabled?: boolean;
|
||||||
|
value: InferenceType | undefined;
|
||||||
|
providers: InferenceType[];
|
||||||
|
exclude?: InferenceType[];
|
||||||
|
}
|
||||||
|
let { value = $bindable(), disabled, providers, exclude = [] }: Props = $props();
|
||||||
|
|
||||||
|
// Filter options based on optional exclude list
|
||||||
|
const options = $derived(() =>
|
||||||
|
providers.filter(type => !exclude.includes(type)).map(type => ({ value: type, label: type })),
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleOnChange(nValue: { value: string } | undefined): void {
|
||||||
|
if (nValue) {
|
||||||
|
value = nValue.value as InferenceType;
|
||||||
|
} else {
|
||||||
|
value = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Select Inference Runtime"
|
||||||
|
name="select-inference-runtime"
|
||||||
|
disabled={disabled}
|
||||||
|
value={value ? { label: value, value: value } : undefined}
|
||||||
|
onchange={handleOnChange}
|
||||||
|
placeholder="Select Inference Runtime to use"
|
||||||
|
items={options()} />
|
|
@ -421,3 +421,47 @@ test('model-id query should be used to select default model', async () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('models with backend "none" should be filtered out', async () => {
|
||||||
|
const modelsInfoList = writable<ModelInfo[]>([
|
||||||
|
{
|
||||||
|
id: 'model-valid',
|
||||||
|
name: 'Valid Model',
|
||||||
|
description: 'A model with a valid backend',
|
||||||
|
backend: 'llama-cpp',
|
||||||
|
file: {
|
||||||
|
file: 'file',
|
||||||
|
path: '/valid-path',
|
||||||
|
},
|
||||||
|
} as unknown as ModelInfo,
|
||||||
|
{
|
||||||
|
id: 'model-none',
|
||||||
|
name: 'None Backend Model',
|
||||||
|
description: 'A model with backend none',
|
||||||
|
backend: 'none',
|
||||||
|
file: {
|
||||||
|
file: 'file',
|
||||||
|
path: '/none-path',
|
||||||
|
},
|
||||||
|
} as unknown as ModelInfo,
|
||||||
|
]);
|
||||||
|
|
||||||
|
vi.mocked(ModelsInfoStore).modelsInfo = modelsInfoList;
|
||||||
|
router.location.query.set('model-id', 'model-valid');
|
||||||
|
|
||||||
|
render(CreateService);
|
||||||
|
expect(screen.queryByText('None Backend Model')).toBeNull();
|
||||||
|
const createBtn = screen.getByTitle('Create service');
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(createBtn).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
await fireEvent.click(createBtn);
|
||||||
|
|
||||||
|
expect(vi.mocked(studioClient.requestCreateInferenceServer)).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
modelsInfo: [expect.objectContaining({ id: 'model-valid' })],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
|
@ -25,8 +25,8 @@ interface Props {
|
||||||
|
|
||||||
let { trackingId }: Props = $props();
|
let { trackingId }: Props = $props();
|
||||||
|
|
||||||
// List of the models available locally
|
// List of the models available locally exlude models with none backend
|
||||||
let localModels: ModelInfo[] = $derived($modelsInfo.filter(model => model.file));
|
let localModels: ModelInfo[] = $derived($modelsInfo.filter(model => model.file && model.backend !== 'none'));
|
||||||
|
|
||||||
// The container provider connection to use
|
// The container provider connection to use
|
||||||
let containerProviderConnection: ContainerProviderConnectionInfo | undefined = $state(undefined);
|
let containerProviderConnection: ContainerProviderConnectionInfo | undefined = $state(undefined);
|
||||||
|
|
|
@ -16,6 +16,7 @@ import { tasks } from '/@/stores/tasks';
|
||||||
import ModelStatusIcon from '../lib/icons/ModelStatusIcon.svelte';
|
import ModelStatusIcon from '../lib/icons/ModelStatusIcon.svelte';
|
||||||
import { router } from 'tinro';
|
import { router } from 'tinro';
|
||||||
import { faBookOpen, faFileImport } from '@fortawesome/free-solid-svg-icons';
|
import { faBookOpen, faFileImport } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
new TableColumn<ModelInfo>('Status', {
|
new TableColumn<ModelInfo>('Status', {
|
||||||
|
@ -24,21 +25,21 @@ const columns = [
|
||||||
comparator: (a, b): number => (a.file ? 0 : 1) - (b.file ? 0 : 1),
|
comparator: (a, b): number => (a.file ? 0 : 1) - (b.file ? 0 : 1),
|
||||||
}),
|
}),
|
||||||
new TableColumn<ModelInfo>('Name', {
|
new TableColumn<ModelInfo>('Name', {
|
||||||
width: '3fr',
|
width: 'minmax(100px,1fr)',
|
||||||
renderer: ModelColumnName,
|
renderer: ModelColumnName,
|
||||||
comparator: (a, b): number => b.name.localeCompare(a.name),
|
comparator: (a, b): number => b.name.localeCompare(a.name),
|
||||||
}),
|
}),
|
||||||
new TableColumn<ModelInfo>('Size', {
|
new TableColumn<ModelInfo>('Size', {
|
||||||
width: '50px',
|
width: 'minmax(10px,50px)',
|
||||||
renderer: ModelColumnSize,
|
renderer: ModelColumnSize,
|
||||||
comparator: (a, b): number => (a.file?.size ?? 0) - (b.file?.size ?? 0),
|
comparator: (a, b): number => (a.file?.size ?? 0) - (b.file?.size ?? 0),
|
||||||
}),
|
}),
|
||||||
new TableColumn<ModelInfo>('Age', {
|
new TableColumn<ModelInfo>('Age', {
|
||||||
width: '70px',
|
width: 'minmax(10px,70px)',
|
||||||
renderer: ModelColumnAge,
|
renderer: ModelColumnAge,
|
||||||
comparator: (a, b): number => (a.file?.creation?.getTime() ?? 0) - (b.file?.creation?.getTime() ?? 0),
|
comparator: (a, b): number => (a.file?.creation?.getTime() ?? 0) - (b.file?.creation?.getTime() ?? 0),
|
||||||
}),
|
}),
|
||||||
new TableColumn<ModelInfo>('', { width: '225px', align: 'right', renderer: ModelColumnLabels }),
|
new TableColumn<ModelInfo>('', { width: 'minmax(50px,175px)', align: 'right', renderer: ModelColumnLabels }),
|
||||||
new TableColumn<ModelInfo>('Actions', { align: 'right', width: '120px', renderer: ModelColumnActions }),
|
new TableColumn<ModelInfo>('Actions', { align: 'right', width: '120px', renderer: ModelColumnActions }),
|
||||||
];
|
];
|
||||||
const row = new TableRow<ModelInfo>({});
|
const row = new TableRow<ModelInfo>({});
|
||||||
|
@ -70,7 +71,7 @@ onMount(() => {
|
||||||
// Subscribe to the tasks store
|
// Subscribe to the tasks store
|
||||||
const tasksUnsubscribe = tasks.subscribe(value => {
|
const tasksUnsubscribe = tasks.subscribe(value => {
|
||||||
// Filter out duplicates
|
// Filter out duplicates
|
||||||
const modelIds = new Set<string>();
|
const modelIds = new SvelteSet<string>();
|
||||||
pullingTasks = value.reduce((filtered: Task[], task: Task) => {
|
pullingTasks = value.reduce((filtered: Task[], task: Task) => {
|
||||||
if (
|
if (
|
||||||
(task.state === 'loading' || task.state === 'error') &&
|
(task.state === 'loading' || task.state === 'error') &&
|
||||||
|
|
|
@ -55,11 +55,24 @@ const dummyWhisperCppModel: ModelInfo = {
|
||||||
backend: InferenceType.WHISPER_CPP,
|
backend: InferenceType.WHISPER_CPP,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dummyOpenVinoModel: ModelInfo = {
|
||||||
|
id: 'openvino-model-id',
|
||||||
|
name: 'Dummy Openvino model',
|
||||||
|
file: {
|
||||||
|
file: 'file',
|
||||||
|
path: path.resolve(os.tmpdir(), 'path'),
|
||||||
|
},
|
||||||
|
properties: {},
|
||||||
|
description: '',
|
||||||
|
backend: InferenceType.OPENVINO,
|
||||||
|
};
|
||||||
|
|
||||||
vi.mock('../utils/client', async () => {
|
vi.mock('../utils/client', async () => {
|
||||||
return {
|
return {
|
||||||
studioClient: {
|
studioClient: {
|
||||||
requestCreatePlayground: vi.fn(),
|
requestCreatePlayground: vi.fn(),
|
||||||
getExtensionConfiguration: vi.fn().mockResolvedValue({}),
|
getExtensionConfiguration: vi.fn().mockResolvedValue({}),
|
||||||
|
getRegisteredProviders: vi.fn().mockResolvedValue([]),
|
||||||
},
|
},
|
||||||
rpcBrowser: {
|
rpcBrowser: {
|
||||||
subscribe: (): unknown => {
|
subscribe: (): unknown => {
|
||||||
|
@ -88,28 +101,58 @@ beforeEach(() => {
|
||||||
|
|
||||||
const tasksList = writable<Task[]>([]);
|
const tasksList = writable<Task[]>([]);
|
||||||
vi.mocked(tasksStore).tasks = tasksList;
|
vi.mocked(tasksStore).tasks = tasksList;
|
||||||
|
vi.mocked(studioClient.getRegisteredProviders).mockResolvedValue([
|
||||||
|
InferenceType.LLAMA_CPP,
|
||||||
|
InferenceType.WHISPER_CPP,
|
||||||
|
InferenceType.OPENVINO,
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('model should be selected by default', () => {
|
test('model should be selected by default when runtime is set', async () => {
|
||||||
const modelsInfoList = writable<ModelInfo[]>([dummyLlamaCppModel]);
|
const modelsInfoList = writable<ModelInfo[]>([dummyLlamaCppModel]);
|
||||||
vi.mocked(modelsInfoStore).modelsInfo = modelsInfoList;
|
vi.mocked(modelsInfoStore).modelsInfo = modelsInfoList;
|
||||||
|
|
||||||
vi.mocked(studioClient.requestCreatePlayground).mockRejectedValue('error creating playground');
|
vi.mocked(studioClient.requestCreatePlayground).mockRejectedValue('error creating playground');
|
||||||
|
|
||||||
const { container } = render(PlaygroundCreate);
|
const { container } = render(PlaygroundCreate, { props: { exclude: [InferenceType.NONE] } });
|
||||||
|
|
||||||
|
// Select our runtime
|
||||||
|
const dropdown = within(container).getByLabelText('Select Inference Runtime');
|
||||||
|
await userEvent.click(dropdown);
|
||||||
|
|
||||||
|
const llamacppOption = within(container).getByText(InferenceType.LLAMA_CPP);
|
||||||
|
await userEvent.click(llamacppOption);
|
||||||
|
|
||||||
const model = within(container).getByText(dummyLlamaCppModel.name);
|
const model = within(container).getByText(dummyLlamaCppModel.name);
|
||||||
expect(model).toBeInTheDocument();
|
expect(model).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('models with incompatible backend should not be listed', async () => {
|
test('selecting a runtime filters the displayed models', async () => {
|
||||||
const modelsInfoList = writable<ModelInfo[]>([dummyWhisperCppModel]);
|
const modelsInfoList = writable<ModelInfo[]>([dummyLlamaCppModel, dummyWhisperCppModel, dummyOpenVinoModel]);
|
||||||
|
vi.mocked(modelsInfoStore).modelsInfo = modelsInfoList;
|
||||||
|
|
||||||
|
const { container } = render(PlaygroundCreate, { props: { exclude: [InferenceType.NONE] } });
|
||||||
|
|
||||||
|
// Select our runtime
|
||||||
|
const dropdown = within(container).getByLabelText('Select Inference Runtime');
|
||||||
|
await userEvent.click(dropdown);
|
||||||
|
|
||||||
|
const openvinoOption = within(container).getByText(InferenceType.OPENVINO);
|
||||||
|
await userEvent.click(openvinoOption);
|
||||||
|
|
||||||
|
expect(within(container).queryByText(dummyOpenVinoModel.name)).toBeInTheDocument();
|
||||||
|
expect(within(container).queryByText(dummyLlamaCppModel.name)).toBeNull();
|
||||||
|
expect(within(container).queryByText(dummyWhisperCppModel.name)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show warning when no local models are available', () => {
|
||||||
|
const modelsInfoList = writable<ModelInfo[]>([]);
|
||||||
vi.mocked(modelsInfoStore).modelsInfo = modelsInfoList;
|
vi.mocked(modelsInfoStore).modelsInfo = modelsInfoList;
|
||||||
|
|
||||||
const { container } = render(PlaygroundCreate);
|
const { container } = render(PlaygroundCreate);
|
||||||
|
|
||||||
const model = within(container).queryByText(dummyWhisperCppModel.name);
|
const warning = within(container).getByText(/You don't have any models downloaded/);
|
||||||
expect(model).toBeNull();
|
expect(warning).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display error message if createPlayground fails', async () => {
|
test('should display error message if createPlayground fails', async () => {
|
||||||
|
@ -123,6 +166,13 @@ test('should display error message if createPlayground fails', async () => {
|
||||||
const errorMessage = within(container).queryByLabelText('Error Message Content');
|
const errorMessage = within(container).queryByLabelText('Error Message Content');
|
||||||
expect(errorMessage).not.toBeInTheDocument();
|
expect(errorMessage).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// Select the runtime first
|
||||||
|
const runtimeDropdown = within(container).getByLabelText('Select Inference Runtime');
|
||||||
|
await userEvent.click(runtimeDropdown);
|
||||||
|
|
||||||
|
const runtimeOption = within(container).getByText(InferenceType.LLAMA_CPP);
|
||||||
|
await userEvent.click(runtimeOption);
|
||||||
|
|
||||||
const createButton = within(container).getByTitle('Create playground');
|
const createButton = within(container).getByTitle('Create playground');
|
||||||
await userEvent.click(createButton);
|
await userEvent.click(createButton);
|
||||||
|
|
||||||
|
|
|
@ -14,9 +14,34 @@ import type { Unsubscriber } from 'svelte/store';
|
||||||
import { Button, ErrorMessage, FormPage, Input } from '@podman-desktop/ui-svelte';
|
import { Button, ErrorMessage, FormPage, Input } from '@podman-desktop/ui-svelte';
|
||||||
import ModelSelect from '/@/lib/select/ModelSelect.svelte';
|
import ModelSelect from '/@/lib/select/ModelSelect.svelte';
|
||||||
import { InferenceType } from '@shared/models/IInference';
|
import { InferenceType } from '@shared/models/IInference';
|
||||||
|
import InferenceRuntimeSelect from '/@/lib/select/InferenceRuntimeSelect.svelte';
|
||||||
|
import { configuration } from '../stores/extensionConfiguration';
|
||||||
|
|
||||||
|
// Get recommended runtime
|
||||||
|
let runtime: InferenceType | undefined = undefined;
|
||||||
|
|
||||||
|
// Exlude certain runtimes from selection
|
||||||
|
export let exclude: InferenceType[] = [InferenceType.NONE, InferenceType.WHISPER_CPP];
|
||||||
|
|
||||||
|
// Get registered list of providers
|
||||||
|
let providers: InferenceType[] = [];
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
providers = await studioClient.getRegisteredProviders();
|
||||||
|
|
||||||
|
const inferenceRuntime = $configuration?.inferenceRuntime;
|
||||||
|
if (
|
||||||
|
Object.values(InferenceType).includes(inferenceRuntime as InferenceType) &&
|
||||||
|
!exclude.includes(inferenceRuntime as InferenceType)
|
||||||
|
) {
|
||||||
|
runtime = inferenceRuntime as InferenceType;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let localModels: ModelInfo[];
|
let localModels: ModelInfo[];
|
||||||
$: localModels = $modelsInfo.filter(model => model.file && model.backend !== InferenceType.WHISPER_CPP);
|
$: localModels = $modelsInfo.filter(
|
||||||
|
model => model.file && (!runtime || model.backend === runtime) && !exclude.includes(model.backend as InferenceType),
|
||||||
|
);
|
||||||
$: availModels = $modelsInfo.filter(model => !model.file);
|
$: availModels = $modelsInfo.filter(model => !model.file);
|
||||||
let model: ModelInfo | undefined = undefined;
|
let model: ModelInfo | undefined = undefined;
|
||||||
let submitted: boolean = false;
|
let submitted: boolean = false;
|
||||||
|
@ -30,10 +55,11 @@ let trackingId: string | undefined = undefined;
|
||||||
// The trackedTasks are the tasks linked to the trackingId
|
// The trackedTasks are the tasks linked to the trackingId
|
||||||
let trackedTasks: Task[] = [];
|
let trackedTasks: Task[] = [];
|
||||||
|
|
||||||
$: {
|
// Preset model selection depending on runtime
|
||||||
if (!model && localModels.length > 0) {
|
$: if (localModels.length > 0) {
|
||||||
model = localModels[0];
|
model = localModels[0];
|
||||||
}
|
} else {
|
||||||
|
model = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openModelsPage(): void {
|
function openModelsPage(): void {
|
||||||
|
@ -145,6 +171,12 @@ export function goToUpPage(): void {
|
||||||
placeholder="Leave blank to generate a name"
|
placeholder="Leave blank to generate a name"
|
||||||
aria-label="playgroundName" />
|
aria-label="playgroundName" />
|
||||||
|
|
||||||
|
<!-- inference runtime -->
|
||||||
|
<label for="inference-runtime" class="pt-4 block mb-2 font-bold text-[var(--pd-content-card-header-text)]">
|
||||||
|
Inference Runtime
|
||||||
|
</label>
|
||||||
|
<InferenceRuntimeSelect bind:value={runtime} providers={providers} exclude={exclude} />
|
||||||
|
|
||||||
<!-- model input -->
|
<!-- model input -->
|
||||||
<label for="model" class="pt-4 block mb-2 font-bold text-[var(--pd-content-card-header-text)]">Model</label>
|
<label for="model" class="pt-4 block mb-2 font-bold text-[var(--pd-content-card-header-text)]">Model</label>
|
||||||
<ModelSelect models={localModels} disabled={submitted} bind:value={model} />
|
<ModelSelect models={localModels} disabled={submitted} bind:value={model} />
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { studioClient } from '../utils/client';
|
||||||
import type { CatalogFilterKey, Choice, RecipeChoices, RecipeFilters } from '@shared/models/FilterRecipesResult';
|
import type { CatalogFilterKey, Choice, RecipeChoices, RecipeFilters } from '@shared/models/FilterRecipesResult';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { configuration } from '../stores/extensionConfiguration';
|
import { configuration } from '../stores/extensionConfiguration';
|
||||||
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
|
|
||||||
// filters available in the dropdowns for the user to select
|
// filters available in the dropdowns for the user to select
|
||||||
let choices: RecipeChoices = $state({});
|
let choices: RecipeChoices = $state({});
|
||||||
|
@ -53,7 +54,7 @@ let groups: Map<Category, Recipe[]> = $derived.by(() => {
|
||||||
if (!Object.keys(categoryDict).length) {
|
if (!Object.keys(categoryDict).length) {
|
||||||
return new Map();
|
return new Map();
|
||||||
}
|
}
|
||||||
const output: Map<Category, Recipe[]> = new Map();
|
const output: Map<Category, Recipe[]> = new SvelteMap();
|
||||||
for (const recipe of recipes) {
|
for (const recipe of recipes) {
|
||||||
if (recipe.categories.length === 0) {
|
if (recipe.categories.length === 0) {
|
||||||
output.set(UNCLASSIFIED, [...(output.get(UNCLASSIFIED) ?? []), recipe]);
|
output.set(UNCLASSIFIED, [...(output.get(UNCLASSIFIED) ?? []), recipe]);
|
||||||
|
|
|
@ -69,6 +69,14 @@ const fakeRecipe: Recipe = {
|
||||||
categories: [],
|
categories: [],
|
||||||
} as unknown as Recipe;
|
} as unknown as Recipe;
|
||||||
|
|
||||||
|
const fakeLlamaStackRecipe: Recipe = {
|
||||||
|
id: 'dummy-llama-stack-recipe-id',
|
||||||
|
backend: 'llama-stack',
|
||||||
|
name: 'Dummy Llama Stack Recipe',
|
||||||
|
description: 'Dummy description',
|
||||||
|
categories: [],
|
||||||
|
} as unknown as Recipe;
|
||||||
|
|
||||||
const fakeRecommendedModel: ModelInfo = {
|
const fakeRecommendedModel: ModelInfo = {
|
||||||
id: 'dummy-model-1',
|
id: 'dummy-model-1',
|
||||||
backend: InferenceType.LLAMA_CPP,
|
backend: InferenceType.LLAMA_CPP,
|
||||||
|
@ -100,7 +108,7 @@ beforeEach(() => {
|
||||||
router.location.query.clear();
|
router.location.query.clear();
|
||||||
|
|
||||||
vi.mocked(CatalogStore).catalog = readable<ApplicationCatalog>({
|
vi.mocked(CatalogStore).catalog = readable<ApplicationCatalog>({
|
||||||
recipes: [fakeRecipe],
|
recipes: [fakeRecipe, fakeLlamaStackRecipe],
|
||||||
models: [],
|
models: [],
|
||||||
categories: [],
|
categories: [],
|
||||||
version: '',
|
version: '',
|
||||||
|
@ -147,7 +155,7 @@ test('Recipe Local Repository should be visible when defined', async () => {
|
||||||
expect(span.textContent).toBe('dummy-recipe-path');
|
expect(span.textContent).toBe('dummy-recipe-path');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Submit button should be disabled when no model is selected', async () => {
|
test('Submit button should be disabled when model is required and no model is selected', async () => {
|
||||||
vi.mocked(ModelsInfoStore).modelsInfo = readable([]);
|
vi.mocked(ModelsInfoStore).modelsInfo = readable([]);
|
||||||
|
|
||||||
render(StartRecipe, {
|
render(StartRecipe, {
|
||||||
|
@ -159,6 +167,18 @@ test('Submit button should be disabled when no model is selected', async () => {
|
||||||
expect(button).toBeDisabled();
|
expect(button).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Submit button should be enabled when model is not required', async () => {
|
||||||
|
vi.mocked(ModelsInfoStore).modelsInfo = readable([]);
|
||||||
|
|
||||||
|
render(StartRecipe, {
|
||||||
|
recipeId: 'dummy-llama-stack-recipe-id',
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = screen.getByTitle(`Start ${fakeLlamaStackRecipe.name} recipe`);
|
||||||
|
expect(button).toBeDefined();
|
||||||
|
expect(button).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
test('First recommended model should be selected as default model', async () => {
|
test('First recommended model should be selected as default model', async () => {
|
||||||
const { container } = render(StartRecipe, {
|
const { container } = render(StartRecipe, {
|
||||||
recipeId: 'dummy-recipe-id',
|
recipeId: 'dummy-recipe-id',
|
||||||
|
@ -265,6 +285,29 @@ test('Submit button should call requestPullApplication with proper arguments', a
|
||||||
connection: containerProviderConnection,
|
connection: containerProviderConnection,
|
||||||
recipeId: fakeRecipe.id,
|
recipeId: fakeRecipe.id,
|
||||||
modelId: fakeRecommendedModel.id,
|
modelId: fakeRecommendedModel.id,
|
||||||
|
dependencies: {
|
||||||
|
llamaStack: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Submit button should call requestPullApplication with proper arguments for llama-stack recipe', async () => {
|
||||||
|
render(StartRecipe, {
|
||||||
|
recipeId: 'dummy-llama-stack-recipe-id',
|
||||||
|
});
|
||||||
|
|
||||||
|
const button = screen.getByTitle(`Start ${fakeLlamaStackRecipe.name} recipe`);
|
||||||
|
expect(button).toBeEnabled();
|
||||||
|
await fireEvent.click(button);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(studioClient.requestPullApplication).toHaveBeenCalledWith({
|
||||||
|
connection: containerProviderConnection,
|
||||||
|
recipeId: fakeLlamaStackRecipe.id,
|
||||||
|
dependencies: {
|
||||||
|
llamaStack: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
import { faFolder, faRocket, faUpRightFromSquare, faWarning } from '@fortawesome/free-solid-svg-icons';
|
import { faFolder, faRocket, faUpRightFromSquare, faWarning } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { catalog } from '/@/stores/catalog';
|
import { catalog } from '/@/stores/catalog';
|
||||||
import Fa from 'svelte-fa';
|
import Fa from 'svelte-fa';
|
||||||
import type { Recipe } from '@shared/models/IRecipe';
|
import type { Recipe, RecipePullOptions, RecipePullOptionsWithModelInference } from '@shared/models/IRecipe';
|
||||||
import type { LocalRepository } from '@shared/models/ILocalRepository';
|
import type { LocalRepository } from '@shared/models/ILocalRepository';
|
||||||
import { findLocalRepositoryByRecipeId } from '/@/utils/localRepositoriesUtils';
|
import { findLocalRepositoryByRecipeId } from '/@/utils/localRepositoriesUtils';
|
||||||
import { localRepositories } from '/@/stores/localRepositories';
|
import { localRepositories } from '/@/stores/localRepositories';
|
||||||
|
@ -53,6 +53,16 @@ let completed: boolean = $state(false);
|
||||||
|
|
||||||
let errorMsg: string | undefined = $state(undefined);
|
let errorMsg: string | undefined = $state(undefined);
|
||||||
|
|
||||||
|
let formValid = $derived.by<boolean>((): boolean => {
|
||||||
|
if (!recipe) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!isModelNeeded(recipe)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return !!model;
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// Select default connection
|
// Select default connection
|
||||||
if (!containerProviderConnection && startedContainerProviderConnectionInfo.length > 0) {
|
if (!containerProviderConnection && startedContainerProviderConnectionInfo.length > 0) {
|
||||||
|
@ -100,16 +110,22 @@ function populateModelFromTasks(trackedTasks: Task[]): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submit(): Promise<void> {
|
async function submit(): Promise<void> {
|
||||||
if (!recipe || !model) return;
|
if (!recipe || !formValid) return;
|
||||||
|
|
||||||
errorMsg = undefined;
|
errorMsg = undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const trackingId = await studioClient.requestPullApplication({
|
const options: RecipePullOptions = {
|
||||||
recipeId: $state.snapshot(recipe.id),
|
recipeId: $state.snapshot(recipe.id),
|
||||||
modelId: $state.snapshot(model.id),
|
|
||||||
connection: $state.snapshot(containerProviderConnection),
|
connection: $state.snapshot(containerProviderConnection),
|
||||||
});
|
dependencies: {
|
||||||
|
llamaStack: recipe.backend === 'llama-stack',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (model) {
|
||||||
|
(options as RecipePullOptionsWithModelInference).modelId = $state.snapshot(model.id);
|
||||||
|
}
|
||||||
|
const trackingId = await studioClient.requestPullApplication(options);
|
||||||
router.location.query.set('trackingId', trackingId);
|
router.location.query.set('trackingId', trackingId);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('Something wrong while trying to create the inference server.', err);
|
console.error('Something wrong while trying to create the inference server.', err);
|
||||||
|
@ -124,6 +140,10 @@ export function goToUpPage(): void {
|
||||||
function handleOnClick(): void {
|
function handleOnClick(): void {
|
||||||
router.goto(`/recipe/${recipeId}/running`);
|
router.goto(`/recipe/${recipeId}/running`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isModelNeeded(recipe: Recipe): boolean {
|
||||||
|
return recipe.backend !== 'llama-stack';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<FormPage
|
<FormPage
|
||||||
|
@ -183,17 +203,18 @@ function handleOnClick(): void {
|
||||||
bind:value={containerProviderConnection}
|
bind:value={containerProviderConnection}
|
||||||
containerProviderConnections={startedContainerProviderConnectionInfo} />
|
containerProviderConnections={startedContainerProviderConnectionInfo} />
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if isModelNeeded(recipe)}
|
||||||
<!-- model form -->
|
<!-- model form -->
|
||||||
<label for="select-model" class="pt-4 block mb-2 font-bold text-[var(--pd-content-card-header-text)]"
|
<label for="select-model" class="pt-4 block mb-2 font-bold text-[var(--pd-content-card-header-text)]"
|
||||||
>Model</label>
|
>Model</label>
|
||||||
<ModelSelect bind:value={model} disabled={loading} recommended={recipe.recommended} models={models} />
|
<ModelSelect bind:value={model} disabled={loading} recommended={recipe.recommended} models={models} />
|
||||||
{#if model && model.file === undefined}
|
{#if model && model.file === undefined}
|
||||||
<div class="text-gray-800 text-sm flex items-center">
|
<div class="text-gray-800 text-sm flex items-center">
|
||||||
<Fa class="mr-2" icon={faWarning} />
|
<Fa class="mr-2" icon={faWarning} />
|
||||||
<span role="alert"
|
<span role="alert"
|
||||||
>The selected model will be downloaded. This action can take some time depending on your connection</span>
|
>The selected model will be downloaded. This action can take some time depending on your connection</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -209,7 +230,7 @@ function handleOnClick(): void {
|
||||||
title="Start {recipe.name} recipe"
|
title="Start {recipe.name} recipe"
|
||||||
inProgress={loading}
|
inProgress={loading}
|
||||||
on:click={submit}
|
on:click={submit}
|
||||||
disabled={!model || loading || !containerProviderConnection}
|
disabled={!formValid || loading || !containerProviderConnection}
|
||||||
icon={faRocket}>
|
icon={faRocket}>
|
||||||
Start {recipe.name} recipe
|
Start {recipe.name} recipe
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
@ -98,8 +98,7 @@ test('Instructions block should be displayed if Llama Stack container is found',
|
||||||
});
|
});
|
||||||
render(StartLlamaStackContainer);
|
render(StartLlamaStackContainer);
|
||||||
|
|
||||||
await tick();
|
await vi.waitFor(() => screen.getByText('Instructions'));
|
||||||
screen.getByText('Instructions');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('start button should be displayed and enabled', async () => {
|
test('start button should be displayed and enabled', async () => {
|
||||||
|
|
|
@ -17,6 +17,7 @@
|
||||||
***********************************************************************/
|
***********************************************************************/
|
||||||
|
|
||||||
import type { ModelInfo } from './models/IModelInfo';
|
import type { ModelInfo } from './models/IModelInfo';
|
||||||
|
import type { InferenceType } from '@shared/models/IInference';
|
||||||
import type { ApplicationCatalog } from './models/IApplicationCatalog';
|
import type { ApplicationCatalog } from './models/IApplicationCatalog';
|
||||||
import type { OpenDialogOptions, Uri } from '@podman-desktop/api';
|
import type { OpenDialogOptions, Uri } from '@podman-desktop/api';
|
||||||
import type { ApplicationState } from './models/IApplicationState';
|
import type { ApplicationState } from './models/IApplicationState';
|
||||||
|
@ -121,6 +122,11 @@ export interface StudioAPI {
|
||||||
*/
|
*/
|
||||||
getInferenceServers(): Promise<InferenceServer[]>;
|
getInferenceServers(): Promise<InferenceServer[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get inference providers
|
||||||
|
*/
|
||||||
|
getRegisteredProviders(): Promise<InferenceType[]>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request to start an inference server
|
* Request to start an inference server
|
||||||
* @param options The options to use
|
* @param options The options to use
|
||||||
|
|
|
@ -19,10 +19,26 @@ import type { ContainerProviderConnectionInfo } from './IContainerConnectionInfo
|
||||||
|
|
||||||
import type { InferenceServer } from './IInference';
|
import type { InferenceServer } from './IInference';
|
||||||
|
|
||||||
export interface RecipePullOptions {
|
export type RecipePullOptions = RecipePullOptionsDefault | RecipePullOptionsWithModelInference;
|
||||||
|
|
||||||
|
export interface RecipePullOptionsDefault {
|
||||||
connection?: ContainerProviderConnectionInfo;
|
connection?: ContainerProviderConnectionInfo;
|
||||||
recipeId: string;
|
recipeId: string;
|
||||||
|
dependencies?: RecipeDependencies;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RecipePullOptionsWithModelInference = RecipePullOptionsDefault & {
|
||||||
modelId: string;
|
modelId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface RecipeDependencies {
|
||||||
|
llamaStack?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRecipePullOptionsWithModelInference(
|
||||||
|
options: RecipePullOptions,
|
||||||
|
): options is RecipePullOptionsWithModelInference {
|
||||||
|
return 'modelId' in options;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecipeComponents {
|
export interface RecipeComponents {
|
||||||
|
|
3689
pnpm-lock.yaml
3689
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
@ -1,20 +1,19 @@
|
||||||
{
|
{
|
||||||
"name": "ai-lab-tests-playwright",
|
"name": "ai-lab-tests-playwright",
|
||||||
"version": "1.7.0-next",
|
"version": "1.9.0-next",
|
||||||
"description": "Podman Desktop AI Lab extension Playwright E2E tests",
|
"description": "Podman Desktop AI Lab extension Playwright E2E tests",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test:e2e": "xvfb-maybe --auto-servernum --server-args='-screen 0 1280x960x24' -- npx playwright test src/",
|
"test:e2e": "xvfb-maybe --auto-servernum --server-args='-screen 0 1280x960x24' -- npx playwright test src/",
|
||||||
"test:e2e:smoke": "xvfb-maybe --auto-servernum --server-args='-screen 0 1280x960x24' -- npx playwright test src/ -g @smoke"
|
"test:e2e:smoke": "xvfb-maybe --auto-servernum --server-args='-screen 0 1280x960x24' -- npx playwright test src/ -g @smoke",
|
||||||
|
"test:e2e:instructlab": "xvfb-maybe --auto-servernum --server-args='-screen 0 1280x960x24' -- npx playwright test src/ -g @instructlab"
|
||||||
},
|
},
|
||||||
"author": "Red Hat",
|
"author": "Red Hat",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.52.0",
|
"@playwright/test": "^1.55.0",
|
||||||
"@podman-desktop/tests-playwright": "1.18.1",
|
"@podman-desktop/tests-playwright": "1.21.0",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
"electron": "^36.2.1",
|
"typescript": "^5.9.2",
|
||||||
"typescript": "^5.8.3",
|
|
||||||
"vitest": "^3.0.5",
|
|
||||||
"xvfb-maybe": "^0.2.1"
|
"xvfb-maybe": "^0.2.1"
|
||||||
},
|
},
|
||||||
"type": "module"
|
"type": "module"
|
||||||
|
|
|
@ -21,6 +21,7 @@ import { defineConfig, devices } from '@playwright/test';
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
outputDir: './output/',
|
outputDir: './output/',
|
||||||
workers: 1,
|
workers: 1,
|
||||||
|
timeout: 60_000,
|
||||||
|
|
||||||
reporter: [
|
reporter: [
|
||||||
['list'],
|
['list'],
|
||||||
|
|
Binary file not shown.
|
@ -16,39 +16,84 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
***********************************************************************/
|
***********************************************************************/
|
||||||
|
|
||||||
import type { Locator, Page } from '@playwright/test';
|
/**
|
||||||
|
* The 'test-audio-to-text.wav' file used in this test was sourced from the
|
||||||
|
* whisper.cpp project (https://github.com/ggml-org/whisper.cpp).
|
||||||
|
* It is licensed under the MIT License (see https://github.com/ggml-org/whisper.cpp/blob/master/LICENSE for details).
|
||||||
|
* This specific WAV file is used solely for Playwright testing purposes within this repository.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { APIResponse, Locator } from '@playwright/test';
|
||||||
import type { NavigationBar, ExtensionsPage } from '@podman-desktop/tests-playwright';
|
import type { NavigationBar, ExtensionsPage } from '@podman-desktop/tests-playwright';
|
||||||
import {
|
import {
|
||||||
|
ContainerDetailsPage,
|
||||||
|
ContainerState,
|
||||||
expect as playExpect,
|
expect as playExpect,
|
||||||
test,
|
test,
|
||||||
RunnerOptions,
|
RunnerOptions,
|
||||||
isWindows,
|
isWindows,
|
||||||
waitForPodmanMachineStartup,
|
waitForPodmanMachineStartup,
|
||||||
isLinux,
|
isLinux,
|
||||||
|
isMac,
|
||||||
|
isCI,
|
||||||
|
resetPodmanMachinesFromCLI,
|
||||||
} from '@podman-desktop/tests-playwright';
|
} from '@podman-desktop/tests-playwright';
|
||||||
import { AILabPage } from './model/ai-lab-page';
|
import type { AILabDashboardPage } from './model/ai-lab-dashboard-page';
|
||||||
import type { AILabRecipesCatalogPage } from './model/ai-lab-recipes-catalog-page';
|
import type { AILabRecipesCatalogPage } from './model/ai-lab-recipes-catalog-page';
|
||||||
import { AILabExtensionDetailsPage } from './model/podman-extension-ai-lab-details-page';
|
|
||||||
import type { AILabCatalogPage } from './model/ai-lab-catalog-page';
|
import type { AILabCatalogPage } from './model/ai-lab-catalog-page';
|
||||||
import { handleWebview } from './utils/webviewHandler';
|
|
||||||
import type { AILabServiceDetailsPage } from './model/ai-lab-service-details-page';
|
import type { AILabServiceDetailsPage } from './model/ai-lab-service-details-page';
|
||||||
import type { AILabPlaygroundsPage } from './model/ai-lab-playgrounds-page';
|
import type { AILabPlaygroundsPage } from './model/ai-lab-playgrounds-page';
|
||||||
import type { AILabPlaygroundDetailsPage } from './model/ai-lab-playground-details-page';
|
import type { AILabPlaygroundDetailsPage } from './model/ai-lab-playground-details-page';
|
||||||
|
import {
|
||||||
|
getExtensionCard,
|
||||||
|
getExtensionVersion,
|
||||||
|
openAILabExtensionDetails,
|
||||||
|
openAILabPreferences,
|
||||||
|
reopenAILabDashboard,
|
||||||
|
waitForExtensionToInitialize,
|
||||||
|
} from './utils/aiLabHandler';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import type { AILabTryInstructLabPage } from './model/ai-lab-try-instructlab-page';
|
||||||
|
|
||||||
const AI_LAB_EXTENSION_OCI_IMAGE =
|
const AI_LAB_EXTENSION_OCI_IMAGE =
|
||||||
process.env.EXTENSION_OCI_IMAGE ?? 'ghcr.io/containers/podman-desktop-extension-ai-lab:nightly';
|
process.env.EXTENSION_OCI_IMAGE ?? 'ghcr.io/containers/podman-desktop-extension-ai-lab:nightly';
|
||||||
const AI_LAB_EXTENSION_PREINSTALLED: boolean = process.env.EXTENSION_PREINSTALLED === 'true';
|
const AI_LAB_EXTENSION_PREINSTALLED: boolean = process.env.EXTENSION_PREINSTALLED === 'true';
|
||||||
const AI_LAB_CATALOG_EXTENSION_LABEL: string = 'redhat.ai-lab';
|
|
||||||
const AI_LAB_CATALOG_EXTENSION_NAME: string = 'Podman AI Lab extension';
|
|
||||||
const AI_LAB_CATALOG_STATUS_ACTIVE: string = 'ACTIVE';
|
const AI_LAB_CATALOG_STATUS_ACTIVE: string = 'ACTIVE';
|
||||||
|
|
||||||
let webview: Page;
|
let aiLabPage: AILabDashboardPage;
|
||||||
let aiLabPage: AILabPage;
|
|
||||||
const runnerOptions = {
|
const runnerOptions = {
|
||||||
customFolder: 'ai-lab-tests-pd',
|
customFolder: 'ai-lab-tests-pd',
|
||||||
aiLabModelUploadDisabled: isWindows ? true : false,
|
aiLabModelUploadDisabled: isWindows ? true : false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface AiApp {
|
||||||
|
appName: string;
|
||||||
|
appModel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AI_APPS: AiApp[] = [
|
||||||
|
{ appName: 'Audio to Text', appModel: 'ggerganov/whisper.cpp' },
|
||||||
|
{ appName: 'ChatBot', appModel: 'ibm-granite/granite-3.3-8b-instruct-GGUF' },
|
||||||
|
{ appName: 'Summarizer', appModel: 'ibm-granite/granite-3.3-8b-instruct-GGUF' },
|
||||||
|
{ appName: 'Code Generation', appModel: 'ibm-granite/granite-3.3-8b-instruct-GGUF' },
|
||||||
|
{ appName: 'RAG Chatbot', appModel: 'ibm-granite/granite-3.3-8b-instruct-GGUF' },
|
||||||
|
{ appName: 'Function calling', appModel: 'ibm-granite/granite-3.3-8b-instruct-GGUF' },
|
||||||
|
{ appName: 'Object Detection', appModel: 'facebook/detr-resnet-101' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const TEST_AUDIO_FILE_PATH: string = path.resolve(
|
||||||
|
__dirname,
|
||||||
|
'..',
|
||||||
|
'..',
|
||||||
|
'playwright',
|
||||||
|
'resources',
|
||||||
|
`test-audio-to-text.wav`,
|
||||||
|
);
|
||||||
|
|
||||||
test.use({
|
test.use({
|
||||||
runnerOptions: new RunnerOptions(runnerOptions),
|
runnerOptions: new RunnerOptions(runnerOptions),
|
||||||
});
|
});
|
||||||
|
@ -63,13 +108,13 @@ test.beforeAll(async ({ runner, welcomePage, page }) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test.afterAll(async ({ runner }) => {
|
test.afterAll(async ({ runner }) => {
|
||||||
test.setTimeout(120_000);
|
test.setTimeout(180_000);
|
||||||
await cleanupServiceModels();
|
await resetPodmanMachinesFromCLI();
|
||||||
await runner.close();
|
await runner.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe.serial(`AI Lab extension installation and verification`, () => {
|
test.describe.serial(`AI Lab extension installation and verification`, () => {
|
||||||
test.describe.serial(`AI Lab extension installation`, { tag: '@smoke' }, () => {
|
test.describe.serial(`AI Lab extension installation`, { tag: ['@smoke', '@instructLab'] }, () => {
|
||||||
let extensionsPage: ExtensionsPage;
|
let extensionsPage: ExtensionsPage;
|
||||||
|
|
||||||
test(`Open Settings -> Extensions page`, async ({ navigationBar }) => {
|
test(`Open Settings -> Extensions page`, async ({ navigationBar }) => {
|
||||||
|
@ -86,40 +131,63 @@ test.describe.serial(`AI Lab extension installation and verification`, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Extension (card) is installed, present and active', async ({ navigationBar }) => {
|
test('Extension (card) is installed, present and active', async ({ navigationBar }) => {
|
||||||
const extensions = await navigationBar.openExtensions();
|
await waitForExtensionToInitialize(navigationBar);
|
||||||
await playExpect
|
const extensionCard = await getExtensionCard(navigationBar);
|
||||||
.poll(async () => await extensions.extensionIsInstalled(AI_LAB_CATALOG_EXTENSION_LABEL), { timeout: 30000 })
|
|
||||||
.toBeTruthy();
|
|
||||||
const extensionCard = await extensions.getInstalledExtension(
|
|
||||||
AI_LAB_CATALOG_EXTENSION_NAME,
|
|
||||||
AI_LAB_CATALOG_EXTENSION_LABEL,
|
|
||||||
);
|
|
||||||
await playExpect(extensionCard.status).toHaveText(AI_LAB_CATALOG_STATUS_ACTIVE);
|
await playExpect(extensionCard.status).toHaveText(AI_LAB_CATALOG_STATUS_ACTIVE);
|
||||||
});
|
});
|
||||||
|
|
||||||
test(`Extension's details show correct status, no error`, async ({ page, navigationBar }) => {
|
test(`Extension's details show correct status, no error`, async ({ navigationBar }) => {
|
||||||
const extensions = await navigationBar.openExtensions();
|
const aiLabExtensionDetailsPage = await openAILabExtensionDetails(navigationBar);
|
||||||
const extensionCard = await extensions.getInstalledExtension('ai-lab', AI_LAB_CATALOG_EXTENSION_LABEL);
|
await aiLabExtensionDetailsPage.waitForLoad();
|
||||||
await extensionCard.openExtensionDetails(AI_LAB_CATALOG_EXTENSION_NAME);
|
await aiLabExtensionDetailsPage.checkIsActive(AI_LAB_CATALOG_STATUS_ACTIVE);
|
||||||
const details = new AILabExtensionDetailsPage(page);
|
await aiLabExtensionDetailsPage.checkForErrors();
|
||||||
await playExpect(details.heading).toBeVisible();
|
|
||||||
await playExpect(details.status).toHaveText(AI_LAB_CATALOG_STATUS_ACTIVE);
|
|
||||||
const errorTab = details.tabs.getByRole('button', { name: 'Error' });
|
|
||||||
// we would like to propagate the error's stack trace into test failure message
|
|
||||||
let stackTrace = '';
|
|
||||||
if ((await errorTab.count()) > 0) {
|
|
||||||
await details.activateTab('Error');
|
|
||||||
stackTrace = await details.errorStackTrace.innerText();
|
|
||||||
}
|
|
||||||
await playExpect(errorTab, `Error Tab was present with stackTrace: ${stackTrace}`).not.toBeVisible();
|
|
||||||
});
|
});
|
||||||
test(`Verify AI Lab extension is installed`, async ({ runner, page, navigationBar }) => {
|
|
||||||
[page, webview] = await handleWebview(runner, page, navigationBar);
|
test(`Verify AI Lab is accessible`, async ({ runner, page, navigationBar }) => {
|
||||||
aiLabPage = new AILabPage(page, webview);
|
aiLabPage = await reopenAILabDashboard(runner, page, navigationBar);
|
||||||
await aiLabPage.navigationBar.waitForLoad();
|
await aiLabPage.navigationBar.waitForLoad();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe.serial(`AI Lab extension GPU preferences`, { tag: '@smoke' }, () => {
|
||||||
|
test(`Verify GPU support banner is visible, preferences are disabled`, async ({ page, navigationBar }) => {
|
||||||
|
test.setTimeout(15_000);
|
||||||
|
await playExpect(aiLabPage.gpuSupportBanner).toBeVisible();
|
||||||
|
await playExpect(aiLabPage.enableGpuButton).toBeVisible();
|
||||||
|
await playExpect(aiLabPage.dontDisplayButton).toBeVisible();
|
||||||
|
const preferencesPage = await openAILabPreferences(navigationBar, page);
|
||||||
|
await preferencesPage.waitForLoad();
|
||||||
|
playExpect(await preferencesPage.isGPUPreferenceEnabled()).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`Enable GPU support and verify preferences`, async ({ runner, page, navigationBar }) => {
|
||||||
|
test.setTimeout(30_000);
|
||||||
|
aiLabPage = await reopenAILabDashboard(runner, page, navigationBar);
|
||||||
|
await aiLabPage.waitForLoad();
|
||||||
|
await aiLabPage.enableGpuSupport();
|
||||||
|
const preferencesPage = await openAILabPreferences(navigationBar, page);
|
||||||
|
await preferencesPage.waitForLoad();
|
||||||
|
playExpect(await preferencesPage.isGPUPreferenceEnabled()).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(
|
||||||
|
`Disable GPU support, return to AI Lab Dashboard and hide banner`,
|
||||||
|
async ({ runner, page, navigationBar }) => {
|
||||||
|
test.setTimeout(30_000);
|
||||||
|
const preferencesPage = await openAILabPreferences(navigationBar, page);
|
||||||
|
await preferencesPage.waitForLoad();
|
||||||
|
await preferencesPage.disableGPUPreference();
|
||||||
|
playExpect(await preferencesPage.isGPUPreferenceEnabled()).toBeFalsy();
|
||||||
|
aiLabPage = await reopenAILabDashboard(runner, page, navigationBar);
|
||||||
|
await playExpect(aiLabPage.gpuSupportBanner).toBeVisible();
|
||||||
|
await playExpect(aiLabPage.enableGpuButton).toBeVisible();
|
||||||
|
await playExpect(aiLabPage.dontDisplayButton).toBeVisible();
|
||||||
|
await aiLabPage.dontDisplayButton.click();
|
||||||
|
await playExpect(aiLabPage.gpuSupportBanner).toBeHidden();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test.describe.serial('AI Lab API endpoint e2e test', { tag: '@smoke' }, () => {
|
test.describe.serial('AI Lab API endpoint e2e test', { tag: '@smoke' }, () => {
|
||||||
let localServerPort: string;
|
let localServerPort: string;
|
||||||
let extensionVersion: string | undefined;
|
let extensionVersion: string | undefined;
|
||||||
|
@ -127,11 +195,8 @@ test.describe.serial(`AI Lab extension installation and verification`, () => {
|
||||||
test.beforeAll(
|
test.beforeAll(
|
||||||
'Get AI Lab extension version and open AI Lab navigation bar',
|
'Get AI Lab extension version and open AI Lab navigation bar',
|
||||||
async ({ page, runner, navigationBar }) => {
|
async ({ page, runner, navigationBar }) => {
|
||||||
const extensions = await navigationBar.openExtensions();
|
extensionVersion = await getExtensionVersion(navigationBar);
|
||||||
extensionVersion = await extensions.getInstalledExtensionVersion('ai-lab', AI_LAB_CATALOG_EXTENSION_LABEL);
|
aiLabPage = await reopenAILabDashboard(runner, page, navigationBar);
|
||||||
|
|
||||||
[page, webview] = await handleWebview(runner, page, navigationBar);
|
|
||||||
aiLabPage = new AILabPage(page, webview);
|
|
||||||
await aiLabPage.navigationBar.waitForLoad();
|
await aiLabPage.navigationBar.waitForLoad();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -160,8 +225,9 @@ test.describe.serial(`AI Lab extension installation and verification`, () => {
|
||||||
playExpect(apiResponse.version).toBe(extensionVersion);
|
playExpect(apiResponse.version).toBe(extensionVersion);
|
||||||
});
|
});
|
||||||
|
|
||||||
test(`Download ${model} via API`, async ({ request }) => {
|
// This test is currently failing due to a known issue: https://github.com/containers/podman-desktop-extension-ai-lab/issues/2925
|
||||||
test.setTimeout(300_000);
|
test.skip(`Download ${model} via API`, async ({ request }) => {
|
||||||
|
test.setTimeout(610_000);
|
||||||
const catalogPage = await aiLabPage.navigationBar.openCatalog();
|
const catalogPage = await aiLabPage.navigationBar.openCatalog();
|
||||||
await catalogPage.waitForLoad();
|
await catalogPage.waitForLoad();
|
||||||
console.log(`Downloading ${model}...`);
|
console.log(`Downloading ${model}...`);
|
||||||
|
@ -175,17 +241,13 @@ test.describe.serial(`AI Lab extension installation and verification`, () => {
|
||||||
insecure: false,
|
insecure: false,
|
||||||
stream: true,
|
stream: true,
|
||||||
},
|
},
|
||||||
timeout: 300_000,
|
timeout: 600_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const body = await response.body();
|
const body = await response.body();
|
||||||
const text = body.toString();
|
const text = body.toString();
|
||||||
playExpect(text).toContain('success');
|
playExpect(text).toContain('success');
|
||||||
});
|
await aiLabPage.navigationBar.openCatalog();
|
||||||
|
|
||||||
// This test is currently failing due to a known issue: https://github.com/containers/podman-desktop-extension-ai-lab/issues/2925
|
|
||||||
test.fail(`Verify ${model} is available in AI Lab Catalog`, async () => {
|
|
||||||
const catalogPage = await aiLabPage.navigationBar.openCatalog();
|
|
||||||
await catalogPage.waitForLoad();
|
await catalogPage.waitForLoad();
|
||||||
await playExpect
|
await playExpect
|
||||||
// eslint-disable-next-line sonarjs/no-nested-functions
|
// eslint-disable-next-line sonarjs/no-nested-functions
|
||||||
|
@ -194,7 +256,7 @@ test.describe.serial(`AI Lab extension installation and verification`, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// This test is currently failing due to a known issue: https://github.com/containers/podman-desktop-extension-ai-lab/issues/2925
|
// This test is currently failing due to a known issue: https://github.com/containers/podman-desktop-extension-ai-lab/issues/2925
|
||||||
test.fail(`Verify ${model} is listed in models fetched from API`, async ({ request }) => {
|
test.skip(`Verify ${model} is listed in models fetched from API`, async ({ request }) => {
|
||||||
const response = await request.get(`http://127.0.0.1:${localServerPort}/api/tags`, {
|
const response = await request.get(`http://127.0.0.1:${localServerPort}/api/tags`, {
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
|
@ -208,7 +270,7 @@ test.describe.serial(`AI Lab extension installation and verification`, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// This test is currently failing due to a known issue: https://github.com/containers/podman-desktop-extension-ai-lab/issues/2925
|
// This test is currently failing due to a known issue: https://github.com/containers/podman-desktop-extension-ai-lab/issues/2925
|
||||||
test.fail(`Delete ${model} model`, async () => {
|
test.skip(`Delete ${model} model`, async () => {
|
||||||
test.skip(isWindows, 'Model deletion is currently very buggy in azure cicd');
|
test.skip(isWindows, 'Model deletion is currently very buggy in azure cicd');
|
||||||
test.setTimeout(310_000);
|
test.setTimeout(310_000);
|
||||||
const catalogPage = await aiLabPage.navigationBar.openCatalog();
|
const catalogPage = await aiLabPage.navigationBar.openCatalog();
|
||||||
|
@ -227,8 +289,7 @@ test.describe.serial(`AI Lab extension installation and verification`, () => {
|
||||||
let catalogPage: AILabCatalogPage;
|
let catalogPage: AILabCatalogPage;
|
||||||
|
|
||||||
test.beforeEach(`Open AI Lab Catalog`, async ({ runner, page, navigationBar }) => {
|
test.beforeEach(`Open AI Lab Catalog`, async ({ runner, page, navigationBar }) => {
|
||||||
[page, webview] = await handleWebview(runner, page, navigationBar);
|
aiLabPage = await reopenAILabDashboard(runner, page, navigationBar);
|
||||||
aiLabPage = new AILabPage(page, webview);
|
|
||||||
await aiLabPage.navigationBar.waitForLoad();
|
await aiLabPage.navigationBar.waitForLoad();
|
||||||
|
|
||||||
catalogPage = await aiLabPage.navigationBar.openCatalog();
|
catalogPage = await aiLabPage.navigationBar.openCatalog();
|
||||||
|
@ -236,24 +297,24 @@ test.describe.serial(`AI Lab extension installation and verification`, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test(`Download ${modelName} model`, async () => {
|
test(`Download ${modelName} model`, async () => {
|
||||||
test.setTimeout(310_000);
|
test.setTimeout(610_000);
|
||||||
if (!(await catalogPage.isModelDownloaded(modelName))) {
|
if (!(await catalogPage.isModelDownloaded(modelName))) {
|
||||||
await catalogPage.downloadModel(modelName);
|
await catalogPage.downloadModel(modelName);
|
||||||
}
|
}
|
||||||
await playExpect
|
await playExpect
|
||||||
// eslint-disable-next-line sonarjs/no-nested-functions
|
// eslint-disable-next-line sonarjs/no-nested-functions
|
||||||
.poll(async () => await waitForCatalogModel(modelName), { timeout: 300_000, intervals: [5_000] })
|
.poll(async () => await waitForCatalogModel(modelName), { timeout: 600_000, intervals: [5_000] })
|
||||||
.toBeTruthy();
|
.toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test(`Delete ${modelName} model`, async () => {
|
test(`Delete ${modelName} model`, async () => {
|
||||||
test.skip(isWindows, 'Model deletion is currently very buggy in azure cicd');
|
test.skip(isWindows, 'Model deletion is currently very buggy in azure cicd');
|
||||||
test.setTimeout(310_000);
|
test.setTimeout(610_000);
|
||||||
playExpect(await catalogPage.isModelDownloaded(modelName)).toBeTruthy();
|
playExpect(await catalogPage.isModelDownloaded(modelName)).toBeTruthy();
|
||||||
await catalogPage.deleteModel(modelName);
|
await catalogPage.deleteModel(modelName);
|
||||||
await playExpect
|
await playExpect
|
||||||
// eslint-disable-next-line sonarjs/no-nested-functions
|
// eslint-disable-next-line sonarjs/no-nested-functions
|
||||||
.poll(async () => await waitForCatalogModel(modelName), { timeout: 300_000, intervals: [2_500] })
|
.poll(async () => await waitForCatalogModel(modelName), { timeout: 600_000, intervals: [2_500] })
|
||||||
.toBeFalsy();
|
.toBeFalsy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -270,8 +331,7 @@ test.describe.serial(`AI Lab extension installation and verification`, () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
test.beforeAll(`Open AI Lab Catalog`, async ({ runner, page, navigationBar }) => {
|
test.beforeAll(`Open AI Lab Catalog`, async ({ runner, page, navigationBar }) => {
|
||||||
[page, webview] = await handleWebview(runner, page, navigationBar);
|
aiLabPage = await reopenAILabDashboard(runner, page, navigationBar);
|
||||||
aiLabPage = new AILabPage(page, webview);
|
|
||||||
await aiLabPage.navigationBar.waitForLoad();
|
await aiLabPage.navigationBar.waitForLoad();
|
||||||
|
|
||||||
catalogPage = await aiLabPage.navigationBar.openCatalog();
|
catalogPage = await aiLabPage.navigationBar.openCatalog();
|
||||||
|
@ -299,6 +359,7 @@ test.describe.serial(`AI Lab extension installation and verification`, () => {
|
||||||
|
|
||||||
await playExpect(modelServiceDetailsPage.modelName).toContainText(modelName);
|
await playExpect(modelServiceDetailsPage.modelName).toContainText(modelName);
|
||||||
await playExpect(modelServiceDetailsPage.inferenceServerType).toContainText('Inference');
|
await playExpect(modelServiceDetailsPage.inferenceServerType).toContainText('Inference');
|
||||||
|
await playExpect(modelServiceDetailsPage.inferenceServerType).toContainText(/CPU|GPU/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test(`Make GET request to the model service for ${modelName}`, async ({ request }) => {
|
test(`Make GET request to the model service for ${modelName}`, async ({ request }) => {
|
||||||
|
@ -342,16 +403,36 @@ test.describe.serial(`AI Lab extension installation and verification`, () => {
|
||||||
}).toPass({ timeout: 600_000, intervals: [5_000] });
|
}).toPass({ timeout: 600_000, intervals: [5_000] });
|
||||||
});
|
});
|
||||||
|
|
||||||
test(`Delete model service for ${modelName}`, async () => {
|
test(`Restart model service for ${modelName}`, async () => {
|
||||||
|
test.skip(modelName === 'ggerganov/whisper.cpp');
|
||||||
|
test.setTimeout(180_000);
|
||||||
|
|
||||||
|
await modelServiceDetailsPage.stopService();
|
||||||
|
await playExpect(modelServiceDetailsPage.startServiceButton).toBeEnabled({ timeout: 120_000 });
|
||||||
|
await playExpect
|
||||||
|
// eslint-disable-next-line sonarjs/no-nested-functions
|
||||||
|
.poll(async () => await modelServiceDetailsPage.getServiceState(), { timeout: 120_000 })
|
||||||
|
.toBe('');
|
||||||
|
|
||||||
|
await modelServiceDetailsPage.startService();
|
||||||
|
await playExpect
|
||||||
|
// eslint-disable-next-line sonarjs/no-nested-functions
|
||||||
|
.poll(async () => await modelServiceDetailsPage.getServiceState(), { timeout: 120_000 })
|
||||||
|
.toBe('RUNNING');
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`Delete model service and model for ${modelName}`, async () => {
|
||||||
test.setTimeout(150_000);
|
test.setTimeout(150_000);
|
||||||
const modelServicePage = await modelServiceDetailsPage.deleteService();
|
await cleanupServices();
|
||||||
await playExpect(modelServicePage.heading).toBeVisible({ timeout: 120_000 });
|
await deleteAllModels();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
['lmstudio-community/granite-3.0-8b-instruct-GGUF'].forEach(modelName => {
|
// Do not use non-instruct models in playground tests.
|
||||||
test.describe.serial(`AI Lab playground creation and deletion`, () => {
|
// They break out of guilderails and fail the tests.
|
||||||
|
['ibm-granite/granite-3.3-8b-instruct-GGUF', 'TheBloke/Mistral-7B-Instruct-v0.2-GGUF'].forEach(modelName => {
|
||||||
|
test.describe.serial(`AI Lab playground creation and deletion for ${modelName}`, { tag: '@smoke' }, () => {
|
||||||
let catalogPage: AILabCatalogPage;
|
let catalogPage: AILabCatalogPage;
|
||||||
let playgroundsPage: AILabPlaygroundsPage;
|
let playgroundsPage: AILabPlaygroundsPage;
|
||||||
let playgroundDetailsPage: AILabPlaygroundDetailsPage;
|
let playgroundDetailsPage: AILabPlaygroundDetailsPage;
|
||||||
|
@ -361,8 +442,7 @@ test.describe.serial(`AI Lab extension installation and verification`, () => {
|
||||||
const systemPrompt = 'Always respond with: "Hello, I am Chat Bot"';
|
const systemPrompt = 'Always respond with: "Hello, I am Chat Bot"';
|
||||||
|
|
||||||
test.beforeAll(`Open AI Lab Catalog`, async ({ runner, page, navigationBar }) => {
|
test.beforeAll(`Open AI Lab Catalog`, async ({ runner, page, navigationBar }) => {
|
||||||
[page, webview] = await handleWebview(runner, page, navigationBar);
|
aiLabPage = await reopenAILabDashboard(runner, page, navigationBar);
|
||||||
aiLabPage = new AILabPage(page, webview);
|
|
||||||
await aiLabPage.navigationBar.waitForLoad();
|
await aiLabPage.navigationBar.waitForLoad();
|
||||||
|
|
||||||
catalogPage = await aiLabPage.navigationBar.openCatalog();
|
catalogPage = await aiLabPage.navigationBar.openCatalog();
|
||||||
|
@ -370,13 +450,13 @@ test.describe.serial(`AI Lab extension installation and verification`, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test(`Download ${modelName} model if not available`, async () => {
|
test(`Download ${modelName} model if not available`, async () => {
|
||||||
test.setTimeout(310_000);
|
test.setTimeout(610_000);
|
||||||
if (!(await catalogPage.isModelDownloaded(modelName))) {
|
if (!(await catalogPage.isModelDownloaded(modelName))) {
|
||||||
await catalogPage.downloadModel(modelName);
|
await catalogPage.downloadModel(modelName);
|
||||||
}
|
}
|
||||||
await playExpect
|
await playExpect
|
||||||
// eslint-disable-next-line sonarjs/no-nested-functions
|
// eslint-disable-next-line sonarjs/no-nested-functions
|
||||||
.poll(async () => await waitForCatalogModel(modelName), { timeout: 300_000, intervals: [5_000] })
|
.poll(async () => await waitForCatalogModel(modelName), { timeout: 600_000, intervals: [5_000] })
|
||||||
.toBeTruthy();
|
.toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -432,12 +512,13 @@ test.describe.serial(`AI Lab extension installation and verification`, () => {
|
||||||
|
|
||||||
test.afterAll(`Cleaning up service model`, async () => {
|
test.afterAll(`Cleaning up service model`, async () => {
|
||||||
test.setTimeout(60_000);
|
test.setTimeout(60_000);
|
||||||
await cleanupServiceModels();
|
await cleanupServices();
|
||||||
|
await deleteAllModels();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
['Audio to Text', 'ChatBot', 'Summarizer', 'Code Generation', 'RAG Chatbot'].forEach(appName => {
|
AI_APPS.forEach(({ appName, appModel }) => {
|
||||||
test.describe.serial(`AI Recipe installation`, () => {
|
test.describe.serial(`AI Recipe installation`, () => {
|
||||||
test.skip(
|
test.skip(
|
||||||
!process.env.EXT_TEST_RAG_CHATBOT && appName === 'RAG Chatbot',
|
!process.env.EXT_TEST_RAG_CHATBOT && appName === 'RAG Chatbot',
|
||||||
|
@ -445,9 +526,8 @@ test.describe.serial(`AI Lab extension installation and verification`, () => {
|
||||||
);
|
);
|
||||||
let recipesCatalogPage: AILabRecipesCatalogPage;
|
let recipesCatalogPage: AILabRecipesCatalogPage;
|
||||||
|
|
||||||
test.beforeEach(`Open Recipes Catalog`, async ({ runner, page, navigationBar }) => {
|
test.beforeAll(`Open Recipes Catalog`, async ({ runner, page, navigationBar }) => {
|
||||||
[page, webview] = await handleWebview(runner, page, navigationBar);
|
aiLabPage = await reopenAILabDashboard(runner, page, navigationBar);
|
||||||
aiLabPage = new AILabPage(page, webview);
|
|
||||||
await aiLabPage.navigationBar.waitForLoad();
|
await aiLabPage.navigationBar.waitForLoad();
|
||||||
|
|
||||||
recipesCatalogPage = await aiLabPage.navigationBar.openRecipesCatalog();
|
recipesCatalogPage = await aiLabPage.navigationBar.openRecipesCatalog();
|
||||||
|
@ -455,23 +535,187 @@ test.describe.serial(`AI Lab extension installation and verification`, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test(`Install ${appName} example app`, async () => {
|
test(`Install ${appName} example app`, async () => {
|
||||||
|
test.skip(
|
||||||
|
appName === 'Object Detection' && isCI && !isMac,
|
||||||
|
'Currently we are facing issues with the Object Detection app installation on Windows and Linux CI.',
|
||||||
|
);
|
||||||
test.setTimeout(1_500_000);
|
test.setTimeout(1_500_000);
|
||||||
const demoApp = await recipesCatalogPage.openRecipesCatalogApp(appName);
|
const demoApp = await recipesCatalogPage.openRecipesCatalogApp(appName);
|
||||||
await demoApp.waitForLoad();
|
await demoApp.waitForLoad();
|
||||||
await demoApp.startNewDeployment();
|
await demoApp.startNewDeployment();
|
||||||
});
|
});
|
||||||
|
|
||||||
test.afterEach(`Stop ${appName} app`, async ({ navigationBar }) => {
|
test(`Verify ${appName} app HTTP page is reachable`, async ({ request }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
/// In the future, we could use this test for other AI applications
|
||||||
|
test.skip(
|
||||||
|
appName !== 'Object Detection' || (isCI && !isMac),
|
||||||
|
'Runs only for Object Detection app on macOS CI or any local platform',
|
||||||
|
);
|
||||||
|
const aiRunningAppsPage = await aiLabPage.navigationBar.openRunningApps();
|
||||||
|
const appPort = await aiRunningAppsPage.getAppPort(appName);
|
||||||
|
const response = await request.get(`http://localhost:${appPort}`, { timeout: 60_000 });
|
||||||
|
|
||||||
|
playExpect(response.ok()).toBeTruthy();
|
||||||
|
const body = await response.text();
|
||||||
|
playExpect(body).toContain('<title>Streamlit</title>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`Verify that model service for the ${appName} is working`, async ({ request }) => {
|
||||||
|
test.skip(appName !== 'Function calling' && appName !== 'Audio to Text');
|
||||||
|
test.fail(
|
||||||
|
appName === 'Audio to Text',
|
||||||
|
'Expected failure due to issue #3111: https://github.com/containers/podman-desktop-extension-ai-lab/issues/3111',
|
||||||
|
);
|
||||||
|
test.setTimeout(600_000);
|
||||||
|
|
||||||
|
const modelServicePage = await aiLabPage.navigationBar.openServices();
|
||||||
|
const serviceDetailsPage = await modelServicePage.openServiceDetails(appModel);
|
||||||
|
|
||||||
|
await playExpect
|
||||||
|
// eslint-disable-next-line sonarjs/no-nested-functions
|
||||||
|
.poll(async () => await serviceDetailsPage.getServiceState(), { timeout: 60_000 })
|
||||||
|
.toBe('RUNNING');
|
||||||
|
|
||||||
|
const port = await serviceDetailsPage.getInferenceServerPort();
|
||||||
|
const baseUrl = `http://localhost:${port}`;
|
||||||
|
|
||||||
|
let response: APIResponse;
|
||||||
|
let expectedResponse: string;
|
||||||
|
|
||||||
|
switch (appModel) {
|
||||||
|
case 'ggerganov/whisper.cpp': {
|
||||||
|
expectedResponse =
|
||||||
|
'And so my fellow Americans, ask not what your country can do for you, ask what you can do for your country';
|
||||||
|
const audioFileContent = fs.readFileSync(TEST_AUDIO_FILE_PATH);
|
||||||
|
|
||||||
|
response = await request.post(`${baseUrl}/inference`, {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
multipart: {
|
||||||
|
file: {
|
||||||
|
name: 'test.wav',
|
||||||
|
mimeType: 'audio/wav',
|
||||||
|
buffer: audioFileContent,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
timeout: 600_000,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ibm-granite/granite-3.3-8b-instruct-GGUF': {
|
||||||
|
expectedResponse = 'Prague';
|
||||||
|
response = await request.post(`${baseUrl}/v1/chat/completions`, {
|
||||||
|
data: {
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: 'You are a helpful assistant.' },
|
||||||
|
{ role: 'user', content: 'What is the capital of Czech Republic?' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
timeout: 600_000,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Unhandled model type: ${appModel}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
playExpect(response.ok()).toBeTruthy();
|
||||||
|
const body = await response.body();
|
||||||
|
const text = body.toString();
|
||||||
|
playExpect(text).toContain(expectedResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`${appName}: Restart, Stop, Delete. Clean up model service`, async () => {
|
||||||
|
test.skip(
|
||||||
|
appName === 'Object Detection' && isCI && !isMac,
|
||||||
|
'Currently we are facing issues with the Object Detection app installation on Windows and Linux CI.',
|
||||||
|
);
|
||||||
test.setTimeout(150_000);
|
test.setTimeout(150_000);
|
||||||
|
|
||||||
|
await restartApp(appName);
|
||||||
await stopAndDeleteApp(appName);
|
await stopAndDeleteApp(appName);
|
||||||
await cleanupServiceModels();
|
await cleanupServices();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(`Ensure cleanup of "${appName}" app, related service, and images`, async ({ navigationBar }) => {
|
||||||
|
test.setTimeout(150_000);
|
||||||
|
|
||||||
|
await stopAndDeleteApp(appName);
|
||||||
|
await cleanupServices();
|
||||||
|
await deleteAllModels();
|
||||||
await deleteUnusedImages(navigationBar);
|
await deleteUnusedImages(navigationBar);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe.serial('InstructLab container startup', { tag: '@instructlab' }, () => {
|
||||||
|
let instructLabPage: AILabTryInstructLabPage;
|
||||||
|
const instructLabContainerName = /^instructlab-\d+$/;
|
||||||
|
let exactInstructLabContainerName = '';
|
||||||
|
test.skip(!!process.env.GITHUB_ACTIONS && !!isLinux);
|
||||||
|
|
||||||
|
test.beforeAll('Open Try InstructLab page', async ({ runner, page, navigationBar }) => {
|
||||||
|
aiLabPage = await reopenAILabDashboard(runner, page, navigationBar);
|
||||||
|
await aiLabPage.navigationBar.waitForLoad();
|
||||||
|
|
||||||
|
instructLabPage = await aiLabPage.navigationBar.openTryInstructLab();
|
||||||
|
await instructLabPage.waitForLoad();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Start and verify InstructLab container', async ({ page }) => {
|
||||||
|
test.setTimeout(1_000_000);
|
||||||
|
await playExpect(instructLabPage.startInstructLabButton).toBeVisible();
|
||||||
|
await playExpect(instructLabPage.startInstructLabButton).toBeEnabled();
|
||||||
|
await instructLabPage.startInstructLabButton.click();
|
||||||
|
|
||||||
|
await playExpect(instructLabPage.openInstructLabButton).toBeVisible({ timeout: 900_000 });
|
||||||
|
await playExpect(instructLabPage.openInstructLabButton).toBeEnabled({ timeout: 10_000 });
|
||||||
|
await playExpect(instructLabPage.statusMessageBox).toContainText('Starting InstructLab container');
|
||||||
|
|
||||||
|
const checkMarkLocator = instructLabPage.statusMessageBox.locator('[class*="text-green"]');
|
||||||
|
await playExpect(checkMarkLocator).toHaveCount(3);
|
||||||
|
await instructLabPage.openInstructLabButton.click();
|
||||||
|
|
||||||
|
const containerName = await page
|
||||||
|
.getByRole('region', { name: 'Header' })
|
||||||
|
.getByLabel(instructLabContainerName)
|
||||||
|
.textContent();
|
||||||
|
if (typeof containerName === 'string') {
|
||||||
|
exactInstructLabContainerName = containerName;
|
||||||
|
}
|
||||||
|
const containerDetailsPage = new ContainerDetailsPage(page, exactInstructLabContainerName);
|
||||||
|
await playExpect(containerDetailsPage.heading).toBeVisible();
|
||||||
|
await playExpect(containerDetailsPage.heading).toContainText(exactInstructLabContainerName);
|
||||||
|
await playExpect
|
||||||
|
.poll(async () => containerDetailsPage.getState(), { timeout: 90_000, intervals: [1_000] })
|
||||||
|
.toContain(ContainerState.Running);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Cleanup the InstructLab container', async ({ runner, page, navigationBar }) => {
|
||||||
|
const containerDetailsPage = new ContainerDetailsPage(page, exactInstructLabContainerName);
|
||||||
|
await playExpect(containerDetailsPage.heading).toBeVisible();
|
||||||
|
|
||||||
|
await containerDetailsPage.deleteContainer();
|
||||||
|
const containersPage = await navigationBar.openContainers();
|
||||||
|
await playExpect(containersPage.heading).toBeVisible({ timeout: 30_000 });
|
||||||
|
await playExpect
|
||||||
|
.poll(async () => containersPage.containerExists(exactInstructLabContainerName), { timeout: 100_000 })
|
||||||
|
.toBeFalsy();
|
||||||
|
|
||||||
|
aiLabPage = await reopenAILabDashboard(runner, page, navigationBar);
|
||||||
|
await aiLabPage.navigationBar.waitForLoad();
|
||||||
|
instructLabPage = await aiLabPage.navigationBar.openTryInstructLab();
|
||||||
|
await instructLabPage.waitForLoad();
|
||||||
|
await playExpect(instructLabPage.startInstructLabButton).toBeEnabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
async function cleanupServiceModels(): Promise<void> {
|
async function cleanupServices(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const modelServicePage = await aiLabPage.navigationBar.openServices();
|
const modelServicePage = await aiLabPage.navigationBar.openServices();
|
||||||
await modelServicePage.waitForLoad();
|
await modelServicePage.waitForLoad();
|
||||||
|
@ -482,9 +726,36 @@ async function cleanupServiceModels(): Promise<void> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteAllModels(): Promise<void> {
|
||||||
|
const modelCatalogPage = await aiLabPage.navigationBar.openCatalog();
|
||||||
|
await modelCatalogPage.waitForLoad();
|
||||||
|
await modelCatalogPage.deleteAllModels();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restartApp(appName: string): Promise<void> {
|
||||||
|
const aiRunningAppsPage = await aiLabPage.navigationBar.openRunningApps();
|
||||||
|
const aiApp = await aiRunningAppsPage.getRowForApp(appName);
|
||||||
|
await aiRunningAppsPage.waitForLoad();
|
||||||
|
await playExpect.poll(async () => await aiRunningAppsPage.appExists(appName), { timeout: 10_000 }).toBeTruthy();
|
||||||
|
await playExpect
|
||||||
|
.poll(async () => await aiRunningAppsPage.getCurrentStatusForApp(appName), { timeout: 60_000 })
|
||||||
|
.toBe('RUNNING');
|
||||||
|
await aiRunningAppsPage.restartApp(appName);
|
||||||
|
|
||||||
|
const appProgressBar = aiApp.getByRole('progressbar', { name: 'Loading' });
|
||||||
|
await playExpect(appProgressBar).toBeVisible({ timeout: 60_000 });
|
||||||
|
await playExpect
|
||||||
|
.poll(async () => await aiRunningAppsPage.getCurrentStatusForApp(appName), { timeout: 60_000 })
|
||||||
|
.toBe('RUNNING');
|
||||||
|
}
|
||||||
|
|
||||||
async function stopAndDeleteApp(appName: string): Promise<void> {
|
async function stopAndDeleteApp(appName: string): Promise<void> {
|
||||||
const aiRunningAppsPage = await aiLabPage.navigationBar.openRunningApps();
|
const aiRunningAppsPage = await aiLabPage.navigationBar.openRunningApps();
|
||||||
await aiRunningAppsPage.waitForLoad();
|
await aiRunningAppsPage.waitForLoad();
|
||||||
|
if (!(await aiRunningAppsPage.appExists(appName))) {
|
||||||
|
console.log(`"${appName}" is not present in the running apps list. Skipping stop and delete operations.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
await playExpect.poll(async () => await aiRunningAppsPage.appExists(appName), { timeout: 10_000 }).toBeTruthy();
|
await playExpect.poll(async () => await aiRunningAppsPage.appExists(appName), { timeout: 10_000 }).toBeTruthy();
|
||||||
await playExpect
|
await playExpect
|
||||||
.poll(async () => await aiRunningAppsPage.getCurrentStatusForApp(appName), { timeout: 60_000 })
|
.poll(async () => await aiRunningAppsPage.getCurrentStatusForApp(appName), { timeout: 60_000 })
|
||||||
|
|
|
@ -17,17 +17,30 @@
|
||||||
***********************************************************************/
|
***********************************************************************/
|
||||||
|
|
||||||
import type { Locator, Page } from '@playwright/test';
|
import type { Locator, Page } from '@playwright/test';
|
||||||
|
import { expect as playExpect } from '@playwright/test';
|
||||||
|
|
||||||
export abstract class AILabBasePage {
|
export abstract class AILabBasePage {
|
||||||
readonly page: Page;
|
readonly page: Page;
|
||||||
readonly webview: Page;
|
readonly webview: Page;
|
||||||
readonly heading: Locator;
|
readonly heading: Locator;
|
||||||
|
readonly gpuSupportBanner: Locator;
|
||||||
|
readonly enableGpuButton: Locator;
|
||||||
|
readonly dontDisplayButton: Locator;
|
||||||
|
|
||||||
constructor(page: Page, webview: Page, heading: string | undefined) {
|
constructor(page: Page, webview: Page, heading: string | undefined) {
|
||||||
this.page = page;
|
this.page = page;
|
||||||
this.webview = webview;
|
this.webview = webview;
|
||||||
this.heading = webview.getByRole('heading', { name: heading, exact: true }).first();
|
this.heading = webview.getByRole('heading', { name: heading, exact: true }).first();
|
||||||
|
this.gpuSupportBanner = this.webview.getByLabel('GPU promotion banner');
|
||||||
|
this.enableGpuButton = this.gpuSupportBanner.getByRole('button', { name: 'Enable GPU support' });
|
||||||
|
this.dontDisplayButton = this.gpuSupportBanner.getByRole('button', { name: `Don't display anymore` });
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract waitForLoad(): Promise<void>;
|
abstract waitForLoad(): Promise<void>;
|
||||||
|
|
||||||
|
async enableGpuSupport(): Promise<void> {
|
||||||
|
await playExpect(this.gpuSupportBanner).toBeVisible();
|
||||||
|
await this.enableGpuButton.click();
|
||||||
|
await playExpect(this.gpuSupportBanner).not.toBeVisible();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,7 +19,7 @@
|
||||||
import type { Locator, Page } from '@playwright/test';
|
import type { Locator, Page } from '@playwright/test';
|
||||||
import { expect as playExpect } from '@playwright/test';
|
import { expect as playExpect } from '@playwright/test';
|
||||||
import { AILabBasePage } from './ai-lab-base-page';
|
import { AILabBasePage } from './ai-lab-base-page';
|
||||||
import { handleConfirmationDialog } from '@podman-desktop/tests-playwright';
|
import { handleConfirmationDialog, podmanAILabExtension } from '@podman-desktop/tests-playwright';
|
||||||
import { AILabCreatingModelServicePage } from './ai-lab-creating-model-service-page';
|
import { AILabCreatingModelServicePage } from './ai-lab-creating-model-service-page';
|
||||||
|
|
||||||
export class AILabCatalogPage extends AILabBasePage {
|
export class AILabCatalogPage extends AILabBasePage {
|
||||||
|
@ -50,6 +50,12 @@ export class AILabCatalogPage extends AILabBasePage {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getModelNameByRow(row: Locator): Promise<string> {
|
||||||
|
const modelNameCell = row.getByLabel('Model Name');
|
||||||
|
const modelName = await modelNameCell.textContent();
|
||||||
|
return modelName?.trim() ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
async downloadModel(modelName: string): Promise<void> {
|
async downloadModel(modelName: string): Promise<void> {
|
||||||
const modelRow = await this.getModelRowByName(modelName);
|
const modelRow = await this.getModelRowByName(modelName);
|
||||||
if (!modelRow) {
|
if (!modelRow) {
|
||||||
|
@ -75,16 +81,35 @@ export class AILabCatalogPage extends AILabBasePage {
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteModel(modelName: string): Promise<void> {
|
async deleteModel(modelName: string): Promise<void> {
|
||||||
|
if (!modelName || modelName.trim() === '') {
|
||||||
|
console.warn('Model name is empty, skipping deletion.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const modelRow = await this.getModelRowByName(modelName);
|
const modelRow = await this.getModelRowByName(modelName);
|
||||||
if (!modelRow) {
|
if (!modelRow) {
|
||||||
throw new Error(`Model ${modelName} not found`);
|
throw new Error(`Model ${modelName} not found`);
|
||||||
}
|
}
|
||||||
const deleteButton = modelRow.getByRole('button', { name: 'Delete Model' });
|
const deleteButton = modelRow.getByRole('button', { name: 'Delete Model' });
|
||||||
await playExpect(deleteButton).toBeEnabled();
|
await playExpect.poll(async () => await deleteButton.isEnabled(), { timeout: 10_000 }).toBeTruthy();
|
||||||
await deleteButton.focus();
|
await deleteButton.focus();
|
||||||
await deleteButton.click();
|
await deleteButton.click();
|
||||||
await this.page.waitForTimeout(1_000);
|
await this.page.waitForTimeout(1_000);
|
||||||
await handleConfirmationDialog(this.page, 'Podman AI Lab', true, 'Confirm');
|
await handleConfirmationDialog(this.page, podmanAILabExtension.extensionName, true, 'Confirm');
|
||||||
|
await playExpect.poll(async () => await this.isModelDownloaded(modelName), { timeout: 30_000 }).toBeFalsy();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAllModels(): Promise<void> {
|
||||||
|
const modelRows = await this.getAllModelRows();
|
||||||
|
if (modelRows.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const modelRow of modelRows) {
|
||||||
|
const modelName = await this.getModelNameByRow(modelRow);
|
||||||
|
if (await this.isModelDownloaded(modelName)) {
|
||||||
|
await this.deleteModel(modelName);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async isModelDownloaded(modelName: string): Promise<boolean> {
|
async isModelDownloaded(modelName: string): Promise<boolean> {
|
||||||
|
|
|
@ -21,7 +21,7 @@ import { expect as playExpect } from '@playwright/test';
|
||||||
import { AILabBasePage } from './ai-lab-base-page';
|
import { AILabBasePage } from './ai-lab-base-page';
|
||||||
import { AILabNavigationBar } from './ai-lab-navigation-bar';
|
import { AILabNavigationBar } from './ai-lab-navigation-bar';
|
||||||
|
|
||||||
export class AILabPage extends AILabBasePage {
|
export class AILabDashboardPage extends AILabBasePage {
|
||||||
readonly navigationBar: AILabNavigationBar;
|
readonly navigationBar: AILabNavigationBar;
|
||||||
|
|
||||||
constructor(page: Page, webview: Page) {
|
constructor(page: Page, webview: Page) {
|
|
@ -19,8 +19,9 @@
|
||||||
import { expect as playExpect } from '@playwright/test';
|
import { expect as playExpect } from '@playwright/test';
|
||||||
import type { Locator, Page } from '@playwright/test';
|
import type { Locator, Page } from '@playwright/test';
|
||||||
import { AILabBasePage } from './ai-lab-base-page';
|
import { AILabBasePage } from './ai-lab-base-page';
|
||||||
import { handleConfirmationDialog } from '@podman-desktop/tests-playwright';
|
import { handleConfirmationDialog, podmanAILabExtension } from '@podman-desktop/tests-playwright';
|
||||||
import { AILabCreatingModelServicePage } from './ai-lab-creating-model-service-page';
|
import { AILabCreatingModelServicePage } from './ai-lab-creating-model-service-page';
|
||||||
|
import { AILabServiceDetailsPage } from './ai-lab-service-details-page';
|
||||||
|
|
||||||
export class AiModelServicePage extends AILabBasePage {
|
export class AiModelServicePage extends AILabBasePage {
|
||||||
readonly additionalActions: Locator;
|
readonly additionalActions: Locator;
|
||||||
|
@ -59,13 +60,35 @@ export class AiModelServicePage extends AILabBasePage {
|
||||||
await playExpect(this.deleteSelectedItems).toBeEnabled();
|
await playExpect(this.deleteSelectedItems).toBeEnabled();
|
||||||
await this.deleteSelectedItems.click();
|
await this.deleteSelectedItems.click();
|
||||||
|
|
||||||
await handleConfirmationDialog(this.page, 'Podman AI Lab', true, 'Confirm');
|
await handleConfirmationDialog(this.page, podmanAILabExtension.extensionName, true, 'Confirm');
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCurrentModelCount(): Promise<number> {
|
async getCurrentModelCount(): Promise<number> {
|
||||||
return (await this.getAllTableRows()).length;
|
return (await this.getAllTableRows()).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async openServiceDetails(modelName: string): Promise<AILabServiceDetailsPage> {
|
||||||
|
const serviceRow = await this.getServiceByModel(modelName);
|
||||||
|
if (serviceRow === undefined) {
|
||||||
|
throw new Error(`Model [${modelName}] service doesn't exist`);
|
||||||
|
}
|
||||||
|
const serviceRowName = serviceRow.getByRole('cell').nth(3);
|
||||||
|
await serviceRowName.click();
|
||||||
|
return new AILabServiceDetailsPage(this.page, this.webview);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getServiceByModel(modelName: string): Promise<Locator | undefined> {
|
||||||
|
const rows = await this.getAllTableRows();
|
||||||
|
for (let rowNum = 1; rowNum < rows.length; rowNum++) {
|
||||||
|
//skip header
|
||||||
|
const serviceModel = rows[rowNum].getByRole('cell').nth(4);
|
||||||
|
if ((await serviceModel.textContent()) === modelName) {
|
||||||
|
return rows[rowNum];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
private async getAllTableRows(): Promise<Locator[]> {
|
private async getAllTableRows(): Promise<Locator[]> {
|
||||||
return await this.webview.getByRole('row').all();
|
return await this.webview.getByRole('row').all();
|
||||||
}
|
}
|
||||||
|
|
|
@ -25,9 +25,12 @@ import { AiModelServicePage } from './ai-lab-model-service-page';
|
||||||
import { AILabCatalogPage } from './ai-lab-catalog-page';
|
import { AILabCatalogPage } from './ai-lab-catalog-page';
|
||||||
import { AILabPlaygroundsPage } from './ai-lab-playgrounds-page';
|
import { AILabPlaygroundsPage } from './ai-lab-playgrounds-page';
|
||||||
import { AILabLocalServerPage } from './ai-lab-local-server-page';
|
import { AILabLocalServerPage } from './ai-lab-local-server-page';
|
||||||
|
import { AILabDashboardPage } from './ai-lab-dashboard-page';
|
||||||
|
import { AILabTryInstructLabPage } from './ai-lab-try-instructlab-page';
|
||||||
|
|
||||||
export class AILabNavigationBar extends AILabBasePage {
|
export class AILabNavigationBar extends AILabBasePage {
|
||||||
readonly navigationBar: Locator;
|
readonly navigationBar: Locator;
|
||||||
|
readonly dashboardButton: Locator;
|
||||||
readonly recipesCatalogButton: Locator;
|
readonly recipesCatalogButton: Locator;
|
||||||
readonly runningAppsButton: Locator;
|
readonly runningAppsButton: Locator;
|
||||||
readonly catalogButton: Locator;
|
readonly catalogButton: Locator;
|
||||||
|
@ -35,10 +38,12 @@ export class AILabNavigationBar extends AILabBasePage {
|
||||||
readonly playgroundsButton: Locator;
|
readonly playgroundsButton: Locator;
|
||||||
readonly tuneButton: Locator;
|
readonly tuneButton: Locator;
|
||||||
readonly localServerButton: Locator;
|
readonly localServerButton: Locator;
|
||||||
|
readonly tryInstructLabButton: Locator;
|
||||||
|
|
||||||
constructor(page: Page, webview: Page) {
|
constructor(page: Page, webview: Page) {
|
||||||
super(page, webview, undefined);
|
super(page, webview, undefined);
|
||||||
this.navigationBar = this.webview.getByRole('navigation', { name: 'PreferencesNavigation' });
|
this.navigationBar = this.webview.getByRole('navigation', { name: 'PreferencesNavigation' });
|
||||||
|
this.dashboardButton = this.navigationBar.getByRole('link', { name: 'Dashboard', exact: true });
|
||||||
this.recipesCatalogButton = this.navigationBar.getByRole('link', { name: 'Recipe Catalog', exact: true });
|
this.recipesCatalogButton = this.navigationBar.getByRole('link', { name: 'Recipe Catalog', exact: true });
|
||||||
this.runningAppsButton = this.navigationBar.getByRole('link', { name: 'Running' });
|
this.runningAppsButton = this.navigationBar.getByRole('link', { name: 'Running' });
|
||||||
this.catalogButton = this.navigationBar.getByRole('link', { name: 'Catalog', exact: true });
|
this.catalogButton = this.navigationBar.getByRole('link', { name: 'Catalog', exact: true });
|
||||||
|
@ -46,12 +51,19 @@ export class AILabNavigationBar extends AILabBasePage {
|
||||||
this.playgroundsButton = this.navigationBar.getByRole('link', { name: 'Playgrounds' });
|
this.playgroundsButton = this.navigationBar.getByRole('link', { name: 'Playgrounds' });
|
||||||
this.tuneButton = this.navigationBar.getByRole('link', { name: 'Tune with InstructLab' });
|
this.tuneButton = this.navigationBar.getByRole('link', { name: 'Tune with InstructLab' });
|
||||||
this.localServerButton = this.navigationBar.getByRole('link', { name: 'Local Server' });
|
this.localServerButton = this.navigationBar.getByRole('link', { name: 'Local Server' });
|
||||||
|
this.tryInstructLabButton = this.navigationBar.getByRole('link', { name: 'Try InstructLab' });
|
||||||
}
|
}
|
||||||
|
|
||||||
async waitForLoad(): Promise<void> {
|
async waitForLoad(): Promise<void> {
|
||||||
await playExpect(this.navigationBar).toBeVisible();
|
await playExpect(this.navigationBar).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async openDashboard(): Promise<AILabDashboardPage> {
|
||||||
|
await playExpect(this.dashboardButton).toBeEnabled();
|
||||||
|
await this.dashboardButton.click();
|
||||||
|
return new AILabDashboardPage(this.page, this.webview);
|
||||||
|
}
|
||||||
|
|
||||||
async openRecipesCatalog(): Promise<AILabRecipesCatalogPage> {
|
async openRecipesCatalog(): Promise<AILabRecipesCatalogPage> {
|
||||||
await playExpect(this.recipesCatalogButton).toBeEnabled();
|
await playExpect(this.recipesCatalogButton).toBeEnabled();
|
||||||
await this.recipesCatalogButton.click();
|
await this.recipesCatalogButton.click();
|
||||||
|
@ -87,4 +99,10 @@ export class AILabNavigationBar extends AILabBasePage {
|
||||||
await this.localServerButton.click();
|
await this.localServerButton.click();
|
||||||
return new AILabLocalServerPage(this.page, this.webview);
|
return new AILabLocalServerPage(this.page, this.webview);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async openTryInstructLab(): Promise<AILabTryInstructLabPage> {
|
||||||
|
await playExpect(this.tryInstructLabButton).toBeEnabled();
|
||||||
|
await this.tryInstructLabButton.click();
|
||||||
|
return new AILabTryInstructLabPage(this.page, this.webview);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,7 +20,7 @@ import { expect as playExpect } from '@playwright/test';
|
||||||
import type { Locator, Page } from '@playwright/test';
|
import type { Locator, Page } from '@playwright/test';
|
||||||
import { AILabBasePage } from './ai-lab-base-page';
|
import { AILabBasePage } from './ai-lab-base-page';
|
||||||
import { AILabPlaygroundsPage } from './ai-lab-playgrounds-page';
|
import { AILabPlaygroundsPage } from './ai-lab-playgrounds-page';
|
||||||
import { handleConfirmationDialog } from '@podman-desktop/tests-playwright';
|
import { handleConfirmationDialog, podmanAILabExtension } from '@podman-desktop/tests-playwright';
|
||||||
|
|
||||||
export class AILabPlaygroundDetailsPage extends AILabBasePage {
|
export class AILabPlaygroundDetailsPage extends AILabBasePage {
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
|
@ -73,14 +73,14 @@ export class AILabPlaygroundDetailsPage extends AILabBasePage {
|
||||||
async deletePlayground(): Promise<AILabPlaygroundsPage> {
|
async deletePlayground(): Promise<AILabPlaygroundsPage> {
|
||||||
await playExpect(this.deletePlaygroundButton).toBeEnabled();
|
await playExpect(this.deletePlaygroundButton).toBeEnabled();
|
||||||
await this.deletePlaygroundButton.click();
|
await this.deletePlaygroundButton.click();
|
||||||
await handleConfirmationDialog(this.page, 'Podman AI Lab', true, 'Confirm');
|
await handleConfirmationDialog(this.page, podmanAILabExtension.extensionName, true, 'Confirm');
|
||||||
return new AILabPlaygroundsPage(this.page, this.webview);
|
return new AILabPlaygroundsPage(this.page, this.webview);
|
||||||
}
|
}
|
||||||
|
|
||||||
async submitUserInput(prompt: string): Promise<void> {
|
async submitUserInput(prompt: string): Promise<void> {
|
||||||
await this.promptTextAreaLocator.fill(prompt);
|
await this.promptTextAreaLocator.fill(prompt);
|
||||||
await playExpect(this.promptTextAreaLocator).toHaveValue(prompt);
|
await playExpect(this.promptTextAreaLocator).toHaveValue(prompt);
|
||||||
await playExpect(this.sendPromptButton).toBeEnabled({ timeout: 30_000 });
|
await playExpect(this.sendPromptButton).toBeEnabled({ timeout: 80_000 });
|
||||||
await this.sendPromptButton.click();
|
await this.sendPromptButton.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -19,7 +19,7 @@
|
||||||
import type { Locator, Page } from '@playwright/test';
|
import type { Locator, Page } from '@playwright/test';
|
||||||
import { expect as playExpect } from '@playwright/test';
|
import { expect as playExpect } from '@playwright/test';
|
||||||
import { AILabBasePage } from './ai-lab-base-page';
|
import { AILabBasePage } from './ai-lab-base-page';
|
||||||
import { handleConfirmationDialog } from '@podman-desktop/tests-playwright';
|
import { handleConfirmationDialog, podmanAILabExtension } from '@podman-desktop/tests-playwright';
|
||||||
import { AILabPlaygroundDetailsPage } from './ai-lab-playground-details-page';
|
import { AILabPlaygroundDetailsPage } from './ai-lab-playground-details-page';
|
||||||
|
|
||||||
export class AILabPlaygroundsPage extends AILabBasePage {
|
export class AILabPlaygroundsPage extends AILabBasePage {
|
||||||
|
@ -60,7 +60,7 @@ export class AILabPlaygroundsPage extends AILabBasePage {
|
||||||
const deleteButton = playgroundRow.getByRole('button', { name: 'Delete conversation', exact: true });
|
const deleteButton = playgroundRow.getByRole('button', { name: 'Delete conversation', exact: true });
|
||||||
await playExpect(deleteButton).toBeEnabled();
|
await playExpect(deleteButton).toBeEnabled();
|
||||||
await deleteButton.click();
|
await deleteButton.click();
|
||||||
await handleConfirmationDialog(this.page, 'Podman AI Lab', true, 'Confirm');
|
await handleConfirmationDialog(this.page, podmanAILabExtension.extensionName, true, 'Confirm');
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
/**********************************************************************
|
/**********************************************************************
|
||||||
* Copyright (C) 2024 Red Hat, Inc.
|
* Copyright (C) 2024-2025 Red Hat, Inc.
|
||||||
*
|
*
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
* you may not use this file except in compliance with the License.
|
* you may not use this file except in compliance with the License.
|
||||||
|
@ -19,7 +19,7 @@
|
||||||
import { expect as playExpect } from '@playwright/test';
|
import { expect as playExpect } from '@playwright/test';
|
||||||
import type { Locator, Page } from '@playwright/test';
|
import type { Locator, Page } from '@playwright/test';
|
||||||
import { AILabBasePage } from './ai-lab-base-page';
|
import { AILabBasePage } from './ai-lab-base-page';
|
||||||
import { handleConfirmationDialog } from '@podman-desktop/tests-playwright';
|
import { handleConfirmationDialog, podmanAILabExtension } from '@podman-desktop/tests-playwright';
|
||||||
|
|
||||||
export class AiRunningAppsPage extends AILabBasePage {
|
export class AiRunningAppsPage extends AILabBasePage {
|
||||||
constructor(page: Page, webview: Page) {
|
constructor(page: Page, webview: Page) {
|
||||||
|
@ -46,6 +46,15 @@ export class AiRunningAppsPage extends AILabBasePage {
|
||||||
return `${await row.getByRole('cell').nth(1).getByRole('status').getAttribute('title', { timeout: 60_000 })}`;
|
return `${await row.getByRole('cell').nth(1).getByRole('status').getAttribute('title', { timeout: 60_000 })}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async restartApp(appName: string): Promise<void> {
|
||||||
|
const dropDownMenu = await this.openKebabMenuForApp(appName);
|
||||||
|
const restartButton = dropDownMenu.getByTitle('Restart AI App');
|
||||||
|
await playExpect(restartButton).toBeVisible();
|
||||||
|
await restartButton.click();
|
||||||
|
|
||||||
|
await handleConfirmationDialog(this.page, 'Podman AI Lab', true, 'Confirm');
|
||||||
|
}
|
||||||
|
|
||||||
async stopApp(appName: string): Promise<void> {
|
async stopApp(appName: string): Promise<void> {
|
||||||
const row = await this.getRowForApp(appName);
|
const row = await this.getRowForApp(appName);
|
||||||
const stopButton = row.getByLabel('Stop AI App');
|
const stopButton = row.getByLabel('Stop AI App');
|
||||||
|
@ -53,20 +62,21 @@ export class AiRunningAppsPage extends AILabBasePage {
|
||||||
await stopButton.click();
|
await stopButton.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
async openKebabMenuForApp(appName: string): Promise<void> {
|
async openKebabMenuForApp(appName: string): Promise<Locator> {
|
||||||
const row = await this.getRowForApp(appName);
|
const row = await this.getRowForApp(appName);
|
||||||
const kebabMenu = row.getByLabel('kebab menu');
|
const kebabMenu = row.getByLabel('kebab menu');
|
||||||
await playExpect(kebabMenu).toBeEnabled();
|
await playExpect(kebabMenu).toBeEnabled();
|
||||||
await kebabMenu.click();
|
await kebabMenu.click();
|
||||||
|
return this.webview.getByTitle('Drop Down Menu Items');
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteAIApp(appName: string): Promise<void> {
|
async deleteAIApp(appName: string): Promise<void> {
|
||||||
await this.openKebabMenuForApp(appName);
|
const dropDownMenu = await this.openKebabMenuForApp(appName);
|
||||||
const deleteButton = this.webview.getByRole('none').nth(2);
|
const deleteButton = dropDownMenu.getByTitle('Delete AI App');
|
||||||
await playExpect(deleteButton).toBeVisible();
|
await playExpect(deleteButton).toBeVisible();
|
||||||
await deleteButton.click();
|
await deleteButton.click();
|
||||||
|
|
||||||
await handleConfirmationDialog(this.page, 'Podman AI Lab', true, 'Confirm');
|
await handleConfirmationDialog(this.page, podmanAILabExtension.extensionName, true, 'Confirm');
|
||||||
}
|
}
|
||||||
|
|
||||||
async appExists(appName: string): Promise<boolean> {
|
async appExists(appName: string): Promise<boolean> {
|
||||||
|
@ -82,6 +92,18 @@ export class AiRunningAppsPage extends AILabBasePage {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAppPort(appName: string): Promise<string> {
|
||||||
|
const appRow = await this.getRowForApp(appName);
|
||||||
|
//Update this locator after issue https://github.com/containers/podman-desktop-extension-ai-lab/issues/3113 is resolved
|
||||||
|
const portCell = appRow.getByRole('cell').nth(3);
|
||||||
|
const rawPortText = await portCell.getByText(/PORT\s\d+/).textContent();
|
||||||
|
if (!rawPortText) {
|
||||||
|
throw new Error(`Failed to extract port for app: ${appName}.`);
|
||||||
|
}
|
||||||
|
const portNumber = rawPortText.replace(/[^\d]/g, '');
|
||||||
|
return portNumber;
|
||||||
|
}
|
||||||
|
|
||||||
private async getAllTableRows(): Promise<Locator[]> {
|
private async getAllTableRows(): Promise<Locator[]> {
|
||||||
return await this.webview.getByRole('row').all();
|
return await this.webview.getByRole('row').all();
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,7 +20,7 @@ import { expect as playExpect } from '@playwright/test';
|
||||||
import type { Locator, Page } from '@playwright/test';
|
import type { Locator, Page } from '@playwright/test';
|
||||||
import { AILabBasePage } from './ai-lab-base-page';
|
import { AILabBasePage } from './ai-lab-base-page';
|
||||||
import { AiModelServicePage } from './ai-lab-model-service-page';
|
import { AiModelServicePage } from './ai-lab-model-service-page';
|
||||||
import { handleConfirmationDialog } from '@podman-desktop/tests-playwright';
|
import { handleConfirmationDialog, podmanAILabExtension } from '@podman-desktop/tests-playwright';
|
||||||
|
|
||||||
export class AILabServiceDetailsPage extends AILabBasePage {
|
export class AILabServiceDetailsPage extends AILabBasePage {
|
||||||
readonly endpointURL: Locator;
|
readonly endpointURL: Locator;
|
||||||
|
@ -29,6 +29,7 @@ export class AILabServiceDetailsPage extends AILabBasePage {
|
||||||
readonly codeSnippet: Locator;
|
readonly codeSnippet: Locator;
|
||||||
readonly deleteServiceButton: Locator;
|
readonly deleteServiceButton: Locator;
|
||||||
readonly stopServiceButton: Locator;
|
readonly stopServiceButton: Locator;
|
||||||
|
readonly startServiceButton: Locator;
|
||||||
|
|
||||||
constructor(page: Page, webview: Page) {
|
constructor(page: Page, webview: Page) {
|
||||||
super(page, webview, 'Service details');
|
super(page, webview, 'Service details');
|
||||||
|
@ -38,6 +39,7 @@ export class AILabServiceDetailsPage extends AILabBasePage {
|
||||||
this.codeSnippet = this.webview.getByLabel('Code Snippet', { exact: true });
|
this.codeSnippet = this.webview.getByLabel('Code Snippet', { exact: true });
|
||||||
this.deleteServiceButton = this.webview.getByRole('button', { name: 'Delete service' });
|
this.deleteServiceButton = this.webview.getByRole('button', { name: 'Delete service' });
|
||||||
this.stopServiceButton = this.webview.getByRole('button', { name: 'Stop service' });
|
this.stopServiceButton = this.webview.getByRole('button', { name: 'Stop service' });
|
||||||
|
this.startServiceButton = this.webview.getByRole('button', { name: 'Start service' });
|
||||||
}
|
}
|
||||||
|
|
||||||
async waitForLoad(): Promise<void> {
|
async waitForLoad(): Promise<void> {
|
||||||
|
@ -47,13 +49,28 @@ export class AILabServiceDetailsPage extends AILabBasePage {
|
||||||
async deleteService(): Promise<AiModelServicePage> {
|
async deleteService(): Promise<AiModelServicePage> {
|
||||||
await playExpect(this.deleteServiceButton).toBeEnabled();
|
await playExpect(this.deleteServiceButton).toBeEnabled();
|
||||||
await this.deleteServiceButton.click();
|
await this.deleteServiceButton.click();
|
||||||
await handleConfirmationDialog(this.page, 'Podman AI Lab', true, 'Confirm');
|
await handleConfirmationDialog(this.page, podmanAILabExtension.extensionName, true, 'Confirm');
|
||||||
return new AiModelServicePage(this.page, this.webview);
|
return new AiModelServicePage(this.page, this.webview);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async stopService(): Promise<void> {
|
||||||
|
await playExpect(this.stopServiceButton).toBeEnabled();
|
||||||
|
await this.stopServiceButton.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async startService(): Promise<void> {
|
||||||
|
await playExpect(this.startServiceButton).toBeEnabled();
|
||||||
|
await this.startServiceButton.click();
|
||||||
|
}
|
||||||
|
|
||||||
async getInferenceServerPort(): Promise<string> {
|
async getInferenceServerPort(): Promise<string> {
|
||||||
const split = (await this.endpointURL.textContent())?.split(':');
|
const split = (await this.endpointURL.textContent())?.split(':');
|
||||||
const port = split ? split[split.length - 1].split('/')[0] : '';
|
const port = split ? split[split.length - 1].split('/')[0] : '';
|
||||||
return port;
|
return port;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getServiceState(): Promise<string> {
|
||||||
|
const serviceState = await this.webview.getByRole('status').getAttribute('title');
|
||||||
|
return serviceState ?? 'UNKNOWN';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,7 +19,7 @@
|
||||||
import { expect as playExpect } from '@playwright/test';
|
import { expect as playExpect } from '@playwright/test';
|
||||||
import type { Locator, Page } from '@playwright/test';
|
import type { Locator, Page } from '@playwright/test';
|
||||||
import { AILabBasePage } from './ai-lab-base-page';
|
import { AILabBasePage } from './ai-lab-base-page';
|
||||||
import { StatusBar, handleConfirmationDialog, waitUntil } from '@podman-desktop/tests-playwright';
|
import { StatusBar, handleConfirmationDialog, podmanAILabExtension, waitUntil } from '@podman-desktop/tests-playwright';
|
||||||
import { AILabNavigationBar } from './ai-lab-navigation-bar';
|
import { AILabNavigationBar } from './ai-lab-navigation-bar';
|
||||||
|
|
||||||
export class AILabStartRecipePage extends AILabBasePage {
|
export class AILabStartRecipePage extends AILabBasePage {
|
||||||
|
@ -33,7 +33,7 @@ export class AILabStartRecipePage extends AILabBasePage {
|
||||||
super(page, webview, 'Start recipe');
|
super(page, webview, 'Start recipe');
|
||||||
this.recipeStatus = this.webview.getByRole('status');
|
this.recipeStatus = this.webview.getByRole('status');
|
||||||
this.applicationDetailsPanel = this.webview.getByLabel('application details panel');
|
this.applicationDetailsPanel = this.webview.getByLabel('application details panel');
|
||||||
this.startRecipeButton = this.webview.getByRole('button', { name: /Start(\s+([a-z]+\s+)+)recipe/i });
|
this.startRecipeButton = this.webview.getByRole('button', { name: /^Start .+ recipe$/i });
|
||||||
this.openAIAppButton = this.applicationDetailsPanel.getByRole('button', { name: 'Open AI App' });
|
this.openAIAppButton = this.applicationDetailsPanel.getByRole('button', { name: 'Open AI App' });
|
||||||
this.deleteAIAppButton = this.applicationDetailsPanel.getByRole('button', { name: 'Delete AI App' });
|
this.deleteAIAppButton = this.applicationDetailsPanel.getByRole('button', { name: 'Delete AI App' });
|
||||||
}
|
}
|
||||||
|
@ -46,7 +46,7 @@ export class AILabStartRecipePage extends AILabBasePage {
|
||||||
await playExpect(this.startRecipeButton).toBeEnabled();
|
await playExpect(this.startRecipeButton).toBeEnabled();
|
||||||
await this.startRecipeButton.click();
|
await this.startRecipeButton.click();
|
||||||
try {
|
try {
|
||||||
await handleConfirmationDialog(this.page, 'Podman AI Lab', true, 'Reset');
|
await handleConfirmationDialog(this.page, podmanAILabExtension.extensionName, true, 'Reset');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`Warning: Could not reset the app, repository probably clean.\n\t${error}`);
|
console.warn(`Warning: Could not reset the app, repository probably clean.\n\t${error}`);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,38 @@
|
||||||
|
/**********************************************************************
|
||||||
|
* Copyright (C) 2025 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
|
||||||
|
*025
|
||||||
|
* 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 { expect as playExpect } from '@playwright/test';
|
||||||
|
import type { Locator, Page } from '@playwright/test';
|
||||||
|
import { AILabBasePage } from './ai-lab-base-page';
|
||||||
|
|
||||||
|
export class AILabTryInstructLabPage extends AILabBasePage {
|
||||||
|
readonly startInstructLabButton: Locator;
|
||||||
|
readonly openInstructLabButton: Locator;
|
||||||
|
readonly statusMessageBox: Locator;
|
||||||
|
|
||||||
|
constructor(page: Page, webview: Page) {
|
||||||
|
super(page, webview, 'Run InstructLab as a container');
|
||||||
|
this.startInstructLabButton = this.webview.getByRole('button', { name: 'Start InstructLab container' });
|
||||||
|
this.openInstructLabButton = this.webview.getByRole('button', { name: 'Open InstructLab container' });
|
||||||
|
this.statusMessageBox = this.webview.getByRole('status');
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForLoad(): Promise<void> {
|
||||||
|
await playExpect(this.heading).toBeVisible();
|
||||||
|
}
|
||||||
|
}
|
|
@ -16,11 +16,32 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
***********************************************************************/
|
***********************************************************************/
|
||||||
|
|
||||||
import type { Page } from '@playwright/test';
|
import type { Locator, Page } from '@playwright/test';
|
||||||
import { ExtensionDetailsPage } from '@podman-desktop/tests-playwright';
|
import { expect as playExpect, ExtensionDetailsPage } from '@podman-desktop/tests-playwright';
|
||||||
|
|
||||||
export class AILabExtensionDetailsPage extends ExtensionDetailsPage {
|
export class AILabExtensionDetailsPage extends ExtensionDetailsPage {
|
||||||
|
readonly errorTab: Locator;
|
||||||
|
|
||||||
constructor(page: Page) {
|
constructor(page: Page) {
|
||||||
super(page, 'Podman AI Lab extension');
|
super(page, 'Podman AI Lab extension');
|
||||||
|
this.errorTab = this.tabs.getByRole('button', { name: 'Error' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForLoad(): Promise<void> {
|
||||||
|
await playExpect(this.heading).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkIsActive(statusTest: string): Promise<void> {
|
||||||
|
await playExpect(this.status).toHaveText(statusTest);
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkForErrors(): Promise<void> {
|
||||||
|
// we would like to propagate the error's stack trace into test failure message
|
||||||
|
let stackTrace = '';
|
||||||
|
if ((await this.errorTab.count()) > 0) {
|
||||||
|
await this.activateTab('Error');
|
||||||
|
stackTrace = await this.errorStackTrace.innerText();
|
||||||
|
}
|
||||||
|
await playExpect(this.errorTab, `Error Tab was present with stackTrace: ${stackTrace}`).not.toBeVisible();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,51 @@
|
||||||
|
/**********************************************************************
|
||||||
|
* Copyright (C) 2025 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 type { Locator, Page } from '@playwright/test';
|
||||||
|
import { expect as playExpect, PreferencesPage } from '@podman-desktop/tests-playwright';
|
||||||
|
|
||||||
|
export class ExtensionAILabPreferencesPage extends PreferencesPage {
|
||||||
|
public static readonly tabName = 'Extension: AI Lab';
|
||||||
|
readonly heading: Locator;
|
||||||
|
readonly experimentalGPUCheckbox: Locator;
|
||||||
|
|
||||||
|
constructor(page: Page) {
|
||||||
|
super(page);
|
||||||
|
this.heading = this.content.getByText(ExtensionAILabPreferencesPage.tabName, { exact: true });
|
||||||
|
this.experimentalGPUCheckbox = this.content.getByRole('checkbox', {
|
||||||
|
name: 'Experimental GPU support for inference servers',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForLoad(): Promise<void> {
|
||||||
|
await playExpect(this.heading).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async disableGPUPreference(): Promise<void> {
|
||||||
|
await this.experimentalGPUCheckbox.uncheck({ force: true });
|
||||||
|
await playExpect(this.experimentalGPUCheckbox).not.toBeChecked();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async enableGPUPreference(): Promise<void> {
|
||||||
|
await this.experimentalGPUCheckbox.check({ force: true });
|
||||||
|
await playExpect(this.experimentalGPUCheckbox).toBeChecked();
|
||||||
|
}
|
||||||
|
public async isGPUPreferenceEnabled(): Promise<boolean> {
|
||||||
|
return await this.experimentalGPUCheckbox.isChecked();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,91 @@
|
||||||
|
/**********************************************************************
|
||||||
|
* Copyright (C) 2025 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 type { Page } from '@playwright/test';
|
||||||
|
import type { Runner, NavigationBar, ExtensionCardPage } from '@podman-desktop/tests-playwright';
|
||||||
|
import { expect as playExpect, podmanAILabExtension } from '@podman-desktop/tests-playwright';
|
||||||
|
import type { AILabDashboardPage } from 'src/model/ai-lab-dashboard-page';
|
||||||
|
import { handleWebview } from './webviewHandler';
|
||||||
|
import { ExtensionAILabPreferencesPage } from 'src/model/preferences-extension-ai-lab-page';
|
||||||
|
import { AILabExtensionDetailsPage } from 'src/model/podman-extension-ai-lab-details-page';
|
||||||
|
|
||||||
|
export async function reopenAILabDashboard(
|
||||||
|
runner: Runner,
|
||||||
|
page: Page,
|
||||||
|
navigationBar: NavigationBar,
|
||||||
|
): Promise<AILabDashboardPage> {
|
||||||
|
const dashboardPage = await navigationBar.openDashboard();
|
||||||
|
await playExpect(dashboardPage.mainPage).toBeVisible();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars, sonarjs/no-unused-vars
|
||||||
|
const [_locPage, _webview, aiLabNavigationBar] = await handleWebview(runner, page, navigationBar);
|
||||||
|
const aiLabDashboardPage = await aiLabNavigationBar.openDashboard();
|
||||||
|
await aiLabDashboardPage.waitForLoad();
|
||||||
|
return aiLabDashboardPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openAILabPreferences(
|
||||||
|
navigationBar: NavigationBar,
|
||||||
|
page: Page,
|
||||||
|
): Promise<ExtensionAILabPreferencesPage> {
|
||||||
|
const dashboardPage = await navigationBar.openDashboard();
|
||||||
|
await playExpect(dashboardPage.mainPage).toBeVisible();
|
||||||
|
const settingsBar = await navigationBar.openSettings();
|
||||||
|
await playExpect(settingsBar.preferencesTab).toBeVisible();
|
||||||
|
await settingsBar.expandPreferencesTab();
|
||||||
|
await playExpect(settingsBar.preferencesTab).toBeVisible();
|
||||||
|
await settingsBar.getPreferencesLinkLocator(ExtensionAILabPreferencesPage.tabName).click();
|
||||||
|
const aiLabPreferencesPage = new ExtensionAILabPreferencesPage(page);
|
||||||
|
await aiLabPreferencesPage.waitForLoad();
|
||||||
|
return aiLabPreferencesPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openAILabExtensionDetails(navigationBar: NavigationBar): Promise<AILabExtensionDetailsPage> {
|
||||||
|
const extensionCard = await getExtensionCard(navigationBar);
|
||||||
|
const extensionDetails = await extensionCard.openExtensionDetails(podmanAILabExtension.extensionFullName);
|
||||||
|
const aiLabExtensionDetails = new AILabExtensionDetailsPage(extensionDetails.page);
|
||||||
|
await aiLabExtensionDetails.waitForLoad();
|
||||||
|
return aiLabExtensionDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getExtensionCard(navigationBar: NavigationBar): Promise<ExtensionCardPage> {
|
||||||
|
const extensions = await navigationBar.openExtensions();
|
||||||
|
const extensionCard = await extensions.getInstalledExtension(
|
||||||
|
podmanAILabExtension.extensionLabel,
|
||||||
|
podmanAILabExtension.extensionFullLabel,
|
||||||
|
);
|
||||||
|
return extensionCard;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForExtensionToInitialize(navigationBar: NavigationBar): Promise<void> {
|
||||||
|
const extensions = await navigationBar.openExtensions();
|
||||||
|
await playExpect
|
||||||
|
.poll(async () => await extensions.extensionIsInstalled(podmanAILabExtension.extensionFullLabel), {
|
||||||
|
timeout: 30000,
|
||||||
|
})
|
||||||
|
.toBeTruthy();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getExtensionVersion(navigationBar: NavigationBar): Promise<string> {
|
||||||
|
const extensionsPage = await navigationBar.openExtensions();
|
||||||
|
const extensionVersion = await extensionsPage.getInstalledExtensionVersion(
|
||||||
|
podmanAILabExtension.extensionLabel,
|
||||||
|
podmanAILabExtension.extensionFullLabel,
|
||||||
|
);
|
||||||
|
playExpect(extensionVersion, `Extension version could not be retrieved.`).toBeDefined();
|
||||||
|
return String(extensionVersion);
|
||||||
|
}
|
|
@ -19,8 +19,13 @@
|
||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
import type { NavigationBar, Runner } from '@podman-desktop/tests-playwright';
|
import type { NavigationBar, Runner } from '@podman-desktop/tests-playwright';
|
||||||
import { expect as playExpect } from '@podman-desktop/tests-playwright';
|
import { expect as playExpect } from '@podman-desktop/tests-playwright';
|
||||||
|
import { AILabNavigationBar } from 'src/model/ai-lab-navigation-bar';
|
||||||
|
|
||||||
export async function handleWebview(runner: Runner, page: Page, navigationBar: NavigationBar): Promise<[Page, Page]> {
|
export async function handleWebview(
|
||||||
|
runner: Runner,
|
||||||
|
page: Page,
|
||||||
|
navigationBar: NavigationBar,
|
||||||
|
): Promise<[Page, Page, AILabNavigationBar]> {
|
||||||
const AI_LAB_NAVBAR_EXTENSION_LABEL: string = 'AI Lab';
|
const AI_LAB_NAVBAR_EXTENSION_LABEL: string = 'AI Lab';
|
||||||
const AI_LAB_PAGE_BODY_LABEL: string = 'Webview AI Lab';
|
const AI_LAB_PAGE_BODY_LABEL: string = 'Webview AI Lab';
|
||||||
|
|
||||||
|
@ -43,6 +48,6 @@ export async function handleWebview(runner: Runner, page: Page, navigationBar: N
|
||||||
console.log(`element is null`);
|
console.log(`element is null`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
const aiLabNavigationBar = new AILabNavigationBar(mainPage, webViewPage);
|
||||||
return [mainPage, webViewPage];
|
return [mainPage, webViewPage, aiLabNavigationBar];
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue