From 855b51cc0a4f3b18ac33613869363f47e5f3ef3c Mon Sep 17 00:00:00 2001 From: Fernando Maclen Date: Wed, 10 Apr 2024 17:22:21 -0400 Subject: [PATCH 01/15] chore: add tests --- package-lock.json | 60 ++++++++++++++++++++++ package.json | 2 + playwright.config.ts | 6 ++- src/routes/+page.svelte | 1 - tests/settings.test.ts | 108 ++++++++++++++++++++++++++++++++++++++++ tests/test.ts | 6 --- 6 files changed, 175 insertions(+), 8 deletions(-) create mode 100644 tests/settings.test.ts delete mode 100644 tests/test.ts diff --git a/package-lock.json b/package-lock.json index f0b061f1..21aa328c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "tailwind-variants": "^0.2.1" }, "devDependencies": { + "@playwright/test": "^1.43.0", "@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/adapter-cloudflare": "^4.2.1", "@sveltejs/kit": "^2.0.0", @@ -917,6 +918,21 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.43.0.tgz", + "integrity": "sha512-Ebw0+MCqoYflop7wVKj711ccbNlrwTBCtjY5rlbiY9kHL2bCYxq+qltK6uPsVBGGAOb033H2VO0YobcQVxoW7Q==", + "dev": true, + "dependencies": { + "playwright": "1.43.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.25", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.25.tgz", @@ -3544,6 +3560,50 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.43.0.tgz", + "integrity": "sha512-SiOKHbVjTSf6wHuGCbqrEyzlm6qvXcv7mENP+OZon1I07brfZLGdfWV0l/efAzVx7TF3Z45ov1gPEkku9q25YQ==", + "dev": true, + "dependencies": { + "playwright-core": "1.43.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.43.0.tgz", + "integrity": "sha512-iWFjyBUH97+pUFiyTqSLd8cDMMOS0r2ZYz2qEsPjH8/bX++sbIJT35MSwKnp1r/OQBAqC5XO99xFbJ9XClhf4w==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.4.38", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", diff --git a/package.json b/package.json index cbf8c97d..417bf138 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,14 @@ "dev": "vite dev --host", "build": "vite build", "preview": "vite preview", + "test": "playwright test --trace on", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "lint": "prettier --check . && eslint .", "format": "prettier --write ." }, "devDependencies": { + "@playwright/test": "^1.43.0", "@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/adapter-cloudflare": "^4.2.1", "@sveltejs/kit": "^2.0.0", diff --git a/playwright.config.ts b/playwright.config.ts index 1c5d7a1f..0827c2d5 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -6,7 +6,11 @@ const config: PlaywrightTestConfig = { port: 4173 }, testDir: 'tests', - testMatch: /(.+\.)?(test|spec)\.[jt]s/ + testMatch: /(.+\.)?(test|spec)\.[jt]s/, + timeout: 5000, + use: { + trace: 'on-first-retry' + } }; export default config; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 07468c3f..2e2875d1 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -116,7 +116,6 @@ - {#if ollamaURL && serverStatus === 'disconnected'}

diff --git a/tests/settings.test.ts b/tests/settings.test.ts new file mode 100644 index 00000000..0eb3c265 --- /dev/null +++ b/tests/settings.test.ts @@ -0,0 +1,108 @@ +import { expect, test } from '@playwright/test'; + +const MOCK_API_TAGS_RESPONSE = { + models: [ + { + name: 'gemma:7b', + model: 'gemma:7b', + modified_at: '2024-04-08T21:41:35.217983842-04:00"', + size: 5011853225, + digest: 'a72c7f4d0a15522df81486d13ce432c79e191bda2558d024fbad4362c4f7cbf8', + details: { + parent_model: '', + format: 'gguf', + family: 'gemma', + families: ["gemma"], + parameter_size: '9B', + quantization_level: 'Q4_0' + } + }, + { + name: "mistral:latest", + model: "mistral:latest", + modified_at: "2023-11-24T16:32:44.035655802-05:00", + size: 4108916866, + digest: "d364aa8d131ef7abfc1275db682d281a307d9451fc00f96abe154d0059b0be49", + details: { + parent_model: "", + format: "gguf", + family: "llama", + families: null, + parameter_size: "7B", + quantization_level: "Q4_0" + } + } + ] +}; + +test.beforeEach(async ({ page }) => { + // Enable request interception + await page.route('**/api/tags', async (route) => { + // Fulfill the request with the mock response + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(MOCK_API_TAGS_RESPONSE) + }); + }); +}); + +test('displays model list and updates settings store', async ({ page }) => { + await page.goto('/'); + + // Wait for the model list to be loaded + await page.waitForSelector('label:has-text("Model")'); + + // Click on the button that opens the model list + await page.click('button[data-melt-select-trigger]'); + + // Wait for the model list to be visible + await page.waitForSelector('div[role="listbox"]'); + + // Check if the model list contains the expected models + const modelNames = await page.$$eval('div[role="option"]', options => + options.map(option => option.textContent?.trim()) + ); + expect(modelNames).toContain('gemma:7b'); + expect(modelNames).toContain('mistral:latest'); + + // Select a model by clicking on its name + await page.click('div[role="option"]:has-text("mistral:latest")'); + + // Check if the settings store is updated with the selected model + const localStorageValue = await page.evaluate(() => window.localStorage.getItem('hollama-settings')); + expect(localStorageValue).toContain('"ollamaModel":"mistral:latest"'); +}); + +test('handles server status updates correctly', async ({ page }) => { + await page.goto('/'); + await page.waitForSelector('label:has-text("Server")'); + + // Mock the API to return a successful response + await page.route('**/api/tags', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(MOCK_API_TAGS_RESPONSE) + }); + }); + + // Trigger a new API request by typing in the input field + await page.fill('input[placeholder="http://localhost:11434"]', 'http://example.com'); + + // Wait for the server status to be updated to "connected" + await expect(page.getByText('connected', { exact: true })).toBeVisible(); + await expect(page.getByText('connected', { exact: true })).toHaveClass(/bg-emerald-600/); + + // Mock the API to return an error response + await page.route('**/api/tags', async (route) => { + await route.abort(); + }); + + // Trigger a new API request by typing in the input field + await page.fill('input[placeholder="http://localhost:11434"]', 'http://example.com/invalid'); + + // Wait for the server status to be updated to "disconnected" + await expect(page.getByText('disconnected')).toBeVisible(); + await expect(page.getByText('disconnected')).toHaveClass(/bg-amber-600/); +}); diff --git a/tests/test.ts b/tests/test.ts deleted file mode 100644 index 5816be41..00000000 --- a/tests/test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { expect, test } from '@playwright/test'; - -test('index page has expected h1', async ({ page }) => { - await page.goto('/'); - await expect(page.getByRole('heading', { name: 'Welcome to SvelteKit' })).toBeVisible(); -}); From 24ef833f56aad7571b1b99895629495b7b519265 Mon Sep 17 00:00:00 2001 From: Fernando Maclen Date: Wed, 10 Apr 2024 17:38:55 -0400 Subject: [PATCH 02/15] add types for ollama /api/tags response --- src/lib/ollama.ts | 24 ++++++++++++++++++++++-- src/routes/+page.svelte | 19 ++++++++----------- tests/settings.test.ts | 3 ++- 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/src/lib/ollama.ts b/src/lib/ollama.ts index 010b0669..187f8062 100644 --- a/src/lib/ollama.ts +++ b/src/lib/ollama.ts @@ -2,12 +2,32 @@ import { get } from "svelte/store"; import type { Session } from "$lib/sessions"; import { settingsStore } from "$lib/store"; -interface OllamaCompletionRequest { +type OllamaCompletionRequest = { model: string; prompt: string; context: number[]; } +export type OllamaModel = { + name: string; + model: string; + modified_at: string; + size: number; + digest: string; + details: { + parent_model: string; + format: string; + family: string; + families: string[] | null; + parameter_size: string; + quantization_level: string; + }; +}; + +export type OllamaTagResponse = { + models: OllamaModel[]; +}; + export async function ollamaGenerate(session: Session) { const settings = get(settingsStore); if (!settings) throw new Error('No Ollama server specified'); @@ -23,4 +43,4 @@ export async function ollamaGenerate(session: Session) { headers: { 'Content-Type': 'text/event-stream' }, body: JSON.stringify(payload) }); -} \ No newline at end of file +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 2e2875d1..55d92852 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -9,9 +9,10 @@ import { slide } from 'svelte/transition'; import Badge from '$lib/components/ui/badge/badge.svelte'; import Separator from '$lib/components/ui/separator/separator.svelte'; + import type { OllamaTagResponse } from '$lib/ollama'; export let ollamaURL: URL | null = null; - let modelList: ModelList | null = null; + let ollamaTagResponse: OllamaTagResponse | null = null; const DETAULT_OLLAMA_SERVER = 'http://localhost:11434'; let ollamaServer = $settingsStore?.ollamaServer || DETAULT_OLLAMA_SERVER; @@ -27,10 +28,6 @@ // key on the Ollama server input. $: typeof ollamaServer === 'string' && getModelsList(); - interface ModelList { - models: Model[]; - } - interface Model { name: string; modified_at: string; @@ -41,11 +38,11 @@ async function getModelsList(): Promise { try { const response = await fetch(`${ollamaServer}/api/tags`); - const data = await response.json(); - modelList = data; + const data = await response.json() as OllamaTagResponse; + ollamaTagResponse = data; serverStatus = 'connected'; } catch { - modelList = null; + ollamaTagResponse = null; serverStatus = 'disconnected'; } } @@ -160,14 +157,14 @@ - {#if modelList} - {#each modelList.models as model} + {#if ollamaTagResponse} + {#each ollamaTagResponse.models as model} {model.name} {/each} {:else} diff --git a/tests/settings.test.ts b/tests/settings.test.ts index 0eb3c265..2d00ec6b 100644 --- a/tests/settings.test.ts +++ b/tests/settings.test.ts @@ -1,6 +1,7 @@ import { expect, test } from '@playwright/test'; +import type { OllamaTagResponse } from '$lib/ollama'; -const MOCK_API_TAGS_RESPONSE = { +const MOCK_API_TAGS_RESPONSE: OllamaTagResponse = { models: [ { name: 'gemma:7b', From 49a7b0df0fbc6d35a59dd9f75d3811785e23c69c Mon Sep 17 00:00:00 2001 From: Fernando Maclen Date: Wed, 10 Apr 2024 17:42:57 -0400 Subject: [PATCH 03/15] chore: add workflow tests --- .github/workflows/test.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..e0c993f2 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,29 @@ +name: Test + +on: + pull_request: + branches: [main, master, develop] + +jobs: + integration-tests: + name: Integration tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + + - name: Install dependencies + run: npm ci + + - name: Install Playwright Browser + working-directory: ./sveltekit + run: npx playwright install + + - name: Run Playwright tests + run: npm test + + - uses: actions/upload-artifact@v3 + if: always() + with: + name: test-results + path: test-results/ From 001a10fba0d506bc3a15ceae99bff9047e5ac1f7 Mon Sep 17 00:00:00 2001 From: Fernando Maclen Date: Wed, 10 Apr 2024 17:43:55 -0400 Subject: [PATCH 04/15] chore: fix path for installing playwright --- .github/workflows/test.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e0c993f2..b18aca06 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,8 +15,7 @@ jobs: - name: Install dependencies run: npm ci - - name: Install Playwright Browser - working-directory: ./sveltekit + - name: Install Playwright dependencies run: npx playwright install - name: Run Playwright tests From c152613b423e533f79b070a8a62e442ce6e26422 Mon Sep 17 00:00:00 2001 From: Fernando Maclen Date: Wed, 10 Apr 2024 17:49:45 -0400 Subject: [PATCH 05/15] update github workflow dependencies to v4 --- .github/workflows/test.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b18aca06..1786b518 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,15 +2,15 @@ name: Test on: pull_request: - branches: [main, master, develop] + branches: main jobs: integration-tests: name: Integration tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 - name: Install dependencies run: npm ci @@ -21,7 +21,7 @@ jobs: - name: Run Playwright tests run: npm test - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 if: always() with: name: test-results From c0b4dd49faeb15f09a6a86d18848b5e7e2237e81 Mon Sep 17 00:00:00 2001 From: Fernando Maclen Date: Wed, 10 Apr 2024 17:49:47 -0400 Subject: [PATCH 06/15] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 81f1f4a3..c10d9606 100644 --- a/.gitignore +++ b/.gitignore @@ -9,5 +9,6 @@ node_modules vite.config.js.timestamp-* vite.config.ts.timestamp-* +/test-results /ai/models /sessions/*.json From e782aa502122306baa462ebbd5b063a7c398ca23 Mon Sep 17 00:00:00 2001 From: Fernando Maclen Date: Wed, 10 Apr 2024 17:51:54 -0400 Subject: [PATCH 07/15] install playwright with deps --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1786b518..2c6e5c35 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,7 +16,7 @@ jobs: run: npm ci - name: Install Playwright dependencies - run: npx playwright install + run: npx playwright install --with-deps - name: Run Playwright tests run: npm test From 90c50841756d245cb7ece328761c9cfbf2ed3606 Mon Sep 17 00:00:00 2001 From: Fernando Maclen Date: Wed, 10 Apr 2024 18:44:24 -0400 Subject: [PATCH 08/15] move tags response to utils.ts --- tests/settings.test.ts | 36 +----------------------------------- tests/utils.ts | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 35 deletions(-) create mode 100644 tests/utils.ts diff --git a/tests/settings.test.ts b/tests/settings.test.ts index 2d00ec6b..52c67cee 100644 --- a/tests/settings.test.ts +++ b/tests/settings.test.ts @@ -1,40 +1,6 @@ import { expect, test } from '@playwright/test'; import type { OllamaTagResponse } from '$lib/ollama'; - -const MOCK_API_TAGS_RESPONSE: OllamaTagResponse = { - models: [ - { - name: 'gemma:7b', - model: 'gemma:7b', - modified_at: '2024-04-08T21:41:35.217983842-04:00"', - size: 5011853225, - digest: 'a72c7f4d0a15522df81486d13ce432c79e191bda2558d024fbad4362c4f7cbf8', - details: { - parent_model: '', - format: 'gguf', - family: 'gemma', - families: ["gemma"], - parameter_size: '9B', - quantization_level: 'Q4_0' - } - }, - { - name: "mistral:latest", - model: "mistral:latest", - modified_at: "2023-11-24T16:32:44.035655802-05:00", - size: 4108916866, - digest: "d364aa8d131ef7abfc1275db682d281a307d9451fc00f96abe154d0059b0be49", - details: { - parent_model: "", - format: "gguf", - family: "llama", - families: null, - parameter_size: "7B", - quantization_level: "Q4_0" - } - } - ] -}; +import { MOCK_API_TAGS_RESPONSE } from './utils'; test.beforeEach(async ({ page }) => { // Enable request interception diff --git a/tests/utils.ts b/tests/utils.ts new file mode 100644 index 00000000..374c6ad4 --- /dev/null +++ b/tests/utils.ts @@ -0,0 +1,36 @@ +import type { OllamaTagResponse } from "$lib/ollama"; + +export const MOCK_API_TAGS_RESPONSE: OllamaTagResponse = { + models: [ + { + name: 'gemma:7b', + model: 'gemma:7b', + modified_at: '2024-04-08T21:41:35.217983842-04:00"', + size: 5011853225, + digest: 'a72c7f4d0a15522df81486d13ce432c79e191bda2558d024fbad4362c4f7cbf8', + details: { + parent_model: '', + format: 'gguf', + family: 'gemma', + families: ["gemma"], + parameter_size: '9B', + quantization_level: 'Q4_0' + } + }, + { + name: "mistral:latest", + model: "mistral:latest", + modified_at: "2023-11-24T16:32:44.035655802-05:00", + size: 4108916866, + digest: "d364aa8d131ef7abfc1275db682d281a307d9451fc00f96abe154d0059b0be49", + details: { + parent_model: "", + format: "gguf", + family: "llama", + families: null, + parameter_size: "7B", + quantization_level: "Q4_0" + } + } + ] +}; From 38154079f442fb139f5a06369001cc13ca94f938 Mon Sep 17 00:00:00 2001 From: Fernando Maclen Date: Wed, 10 Apr 2024 18:44:37 -0400 Subject: [PATCH 09/15] test: creates new session --- src/routes/[id]/+page.svelte | 4 +-- tests/session.test.ts | 62 ++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 tests/session.test.ts diff --git a/src/routes/[id]/+page.svelte b/src/routes/[id]/+page.svelte index 256dd191..8d5f63d6 100644 --- a/src/routes/[id]/+page.svelte +++ b/src/routes/[id]/+page.svelte @@ -99,10 +99,10 @@

-

+

Session #{session.id}

-

{session.model}

+

{session.model}

diff --git a/tests/session.test.ts b/tests/session.test.ts new file mode 100644 index 00000000..765af456 --- /dev/null +++ b/tests/session.test.ts @@ -0,0 +1,62 @@ +import { expect, test } from '@playwright/test'; +import { MOCK_API_TAGS_RESPONSE } from './utils'; + +test.beforeEach(async ({ page }) => { + // Enable request interception and mock the API response + await page.route('**/api/tags', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(MOCK_API_TAGS_RESPONSE) + }); + }); +}); + +test('creates new session', async ({ page }) => { + await page.goto('/'); + await page.click('button[data-melt-select-trigger]'); // Open model list + await page.click('div[role="option"]:has-text("gemma:7b")'); // Select model + await expect(page.getByTestId('session-id')).not.toBeVisible(); + await expect(page.getByTestId('model-name')).not.toBeVisible(); + + await page.getByText('New session').click(); + await expect(page.getByTestId('session-id')).toBeVisible(); + await expect(page.getByTestId('session-id')).toHaveText(/Session #[a-z0-9]{2,8}/); + await expect(page.getByTestId('model-name')).toBeVisible(); + await expect(page.getByTestId('model-name')).toHaveText('gemma:7b'); + await expect(page.getByPlaceholder('Prompt')).toBeVisible(); + await expect(page.getByPlaceholder('Prompt')).toHaveText(''); + await expect(page.getByText('Send')).toBeVisible(); + await expect(page.getByText('Send')).toBeDisabled(); + + await page.getByPlaceholder('Prompt').fill('Who would win in a fight between Emma Watson and Jessica Alba?'); + await expect(page.getByText('Send')).not.toBeDisabled(); +}); + +// test('sends user prompt and receives AI response', async ({ page }) => { +// // TODO: Implement the test +// }); + +// test('handles API error when generating AI response', async ({ page }) => { +// // TODO: Implement the test +// }); + +// test('auto-scrolls to the bottom when new messages are added', async ({ page }) => { +// // TODO: Implement the test +// }); + +// test('resizes the prompt and chat panes', async ({ page }) => { +// // TODO: Implement the test +// }); + +// test('disables the "Send" button when the prompt is empty', async ({ page }) => { +// // TODO: Implement the test +// }); + +// test('submits the prompt when pressing Enter key', async ({ page }) => { +// // TODO: Implement the test +// }); + +// test('displays system message when an error occurs', async ({ page }) => { +// // TODO: Implement the test +// }); From 5c1eef58495bf5dbc04e266b9fd5102910282b30 Mon Sep 17 00:00:00 2001 From: Fernando Maclen Date: Wed, 10 Apr 2024 19:37:26 -0400 Subject: [PATCH 10/15] update type names for Ollama requests and responses --- src/lib/ollama.ts | 16 +++- src/routes/[id]/+page.svelte | 4 +- tests/utils.ts | 149 ++++++++++++++++++++++++++++++++++- 3 files changed, 165 insertions(+), 4 deletions(-) diff --git a/src/lib/ollama.ts b/src/lib/ollama.ts index 187f8062..c3eed29e 100644 --- a/src/lib/ollama.ts +++ b/src/lib/ollama.ts @@ -3,9 +3,23 @@ import type { Session } from "$lib/sessions"; import { settingsStore } from "$lib/store"; type OllamaCompletionRequest = { - model: string; + context: number[]; prompt: string; + model: string; +} + +export type OllamaCompletionResponse = { + model: string; + created_at: string; + response: string; + done: boolean; context: number[]; + total_duration: number; + load_duration: number; + prompt_eval_count: number; + prompt_eval_duration: number; + eval_count: number; + eval_duration: number; } export type OllamaModel = { diff --git a/src/routes/[id]/+page.svelte b/src/routes/[id]/+page.svelte index 8d5f63d6..ac16c940 100644 --- a/src/routes/[id]/+page.svelte +++ b/src/routes/[id]/+page.svelte @@ -4,7 +4,7 @@ import Separator from '$lib/components/ui/separator/separator.svelte'; import Textarea from '$lib/components/ui/textarea/textarea.svelte'; - import { ollamaGenerate } from '$lib/ollama'; + import { ollamaGenerate, type OllamaCompletionResponse } from '$lib/ollama'; import { saveSession, type Message, type Session, loadSession } from '$lib/sessions'; import type { PageData } from './$types'; import Article from './Article.svelte'; @@ -77,7 +77,7 @@ const jsonLines = value.split('\n').filter((line) => line); for (const line of jsonLines) { - const { response, context } = JSON.parse(line); + const { response, context } = JSON.parse(line) as OllamaCompletionResponse; completion += response; session.context = context; } diff --git a/tests/utils.ts b/tests/utils.ts index 374c6ad4..423618dd 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -1,4 +1,4 @@ -import type { OllamaTagResponse } from "$lib/ollama"; +import type { OllamaCompletionResponse, OllamaTagResponse } from "$lib/ollama"; export const MOCK_API_TAGS_RESPONSE: OllamaTagResponse = { models: [ @@ -34,3 +34,150 @@ export const MOCK_API_TAGS_RESPONSE: OllamaTagResponse = { } ] }; + +export const MOCK_COMPLETION_RESPONSE_1: OllamaCompletionResponse = { + model: "gemma:7b", + created_at: "2024-04-10T22:54:40.310905Z", + response: "I am unable to provide subjective or speculative information, including fight outcomes between individuals.", + done: true, + context: [ + 106, + 1645, + 108, + 6571, + 1134, + 3709, + 575, + 476, + 5900, + 1865, + 29349, + 29678, + 578, + 36171, + 68187, + 235336, + 107, + 108, + 106, + 2516, + 108, + 235285, + 1144, + 14321, + 577, + 3658, + 45927, + 689, + 85979, + 2113, + 235269, + 3359, + 5900, + 18701, + 1865, + 9278, + 235265, + 107, + 108 + ], + total_duration: 564546083, + load_duration: 419166, + prompt_eval_count: 18, + prompt_eval_duration: 267210000, + eval_count: 17, + eval_duration: 296374000, +}; + +export const MOCK_COMPLETION_RESPONSE_2: OllamaCompletionResponse = { + model: "gemma:7b", + created_at: "2024-04-10T23:08:33.419483Z", + response: "No problem! If you have any other questions or would like to discuss something else, feel free to ask.", + done: true, + context: [ + 106, + 1645, + 108, + 6571, + 1134, + 3709, + 575, + 476, + 5900, + 1865, + 29349, + 29678, + 578, + 36171, + 68187, + 235336, + 107, + 108, + 106, + 2516, + 108, + 235285, + 1144, + 14321, + 577, + 3658, + 45927, + 689, + 85979, + 2113, + 235269, + 3359, + 5900, + 18701, + 1865, + 9278, + 235265, + 107, + 108, + 106, + 1645, + 108, + 235285, + 3508, + 235269, + 665, + 235303, + 235256, + 12763, + 107, + 108, + 106, + 2516, + 108, + 1294, + 3210, + 235341, + 1927, + 692, + 791, + 1089, + 1156, + 3920, + 689, + 1134, + 1154, + 577, + 9742, + 2775, + 1354, + 235269, + 2375, + 2223, + 577, + 5291, + 235265, + 107, + 108 + ], + total_duration: 1574338000, + load_duration: 1044484792, + prompt_eval_count: 55, + prompt_eval_duration: 130165000, + eval_count: 23, + eval_duration: 399362000, +}; From 551fdd3d9c4e2f5485810a781be6f3f399e70ad9 Mon Sep 17 00:00:00 2001 From: Fernando Maclen Date: Wed, 10 Apr 2024 19:37:50 -0400 Subject: [PATCH 11/15] add more assertions to "create new session" test --- src/routes/[id]/Article.svelte | 2 +- tests/session.test.ts | 75 ++++++++++++++++++++++------------ 2 files changed, 50 insertions(+), 27 deletions(-) diff --git a/src/routes/[id]/Article.svelte b/src/routes/[id]/Article.svelte index c6000725..1f3082c1 100644 --- a/src/routes/[id]/Article.svelte +++ b/src/routes/[id]/Article.svelte @@ -8,7 +8,7 @@
-

{ await expect(page.getByPlaceholder('Prompt')).toHaveText(''); await expect(page.getByText('Send')).toBeVisible(); await expect(page.getByText('Send')).toBeDisabled(); - + await page.getByPlaceholder('Prompt').fill('Who would win in a fight between Emma Watson and Jessica Alba?'); await expect(page.getByText('Send')).not.toBeDisabled(); -}); + await expect(page.locator('article', { + hasText: 'I am unable to provide subjective or speculative information, including fight outcomes between individuals.' + })).not.toBeVisible(); + + await page.route('**/generate', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(MOCK_COMPLETION_RESPONSE_1) + }); + }); -// test('sends user prompt and receives AI response', async ({ page }) => { -// // TODO: Implement the test -// }); + await page.getByText('Send').click(); + await expect(page.locator('article', { + hasText: 'I am unable to provide subjective or speculative information, including fight outcomes between individuals.' + })).toBeVisible(); -// test('handles API error when generating AI response', async ({ page }) => { -// // TODO: Implement the test -// }); + await page.getByPlaceholder('Prompt').fill("I understand, it's okay"); + await expect(page.locator('article', { + hasText: 'No problem! If you have any other questions or would like to discuss something else, feel free to ask' + })).not.toBeVisible(); + await expect(page.locator('article', { hasText: "I understand, it's okay" })).not.toBeVisible(); -// test('auto-scrolls to the bottom when new messages are added', async ({ page }) => { -// // TODO: Implement the test -// }); + await page.route('**/generate', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(MOCK_COMPLETION_RESPONSE_2) + }); + }); -// test('resizes the prompt and chat panes', async ({ page }) => { -// // TODO: Implement the test -// }); + await page.keyboard.press('Enter'); + await expect(page.locator('article', { hasText: "I understand, it's okay" })).toBeVisible(); + await expect(page.locator('article', { + hasText: 'No problem! If you have any other questions or would like to discuss something else, feel free to ask' + })).toBeVisible(); + await expect(page.getByText('AI')).toHaveCount(2); + expect(await page.getByText('You').count()).toBeGreaterThan(1); +}); -// test('disables the "Send" button when the prompt is empty', async ({ page }) => { -// // TODO: Implement the test -// }); +test.skip('handles API error when generating AI response', async ({ page }) => { + // TODO: Implement the test +}); -// test('submits the prompt when pressing Enter key', async ({ page }) => { -// // TODO: Implement the test -// }); +test.skip('displays system message when an error occurs', async ({ page }) => { + // TODO: Implement the test +}); -// test('displays system message when an error occurs', async ({ page }) => { -// // TODO: Implement the test -// }); +test.skip('auto-scrolls to the bottom when new messages are added', async ({ page }) => { + // TODO: Implement the test +}); From 4cc93bb210b57d68e727e39c3fc33226b1a61abb Mon Sep 17 00:00:00 2001 From: Fernando Maclen Date: Wed, 10 Apr 2024 20:00:32 -0400 Subject: [PATCH 12/15] fix bug with New Session id generation --- src/routes/+layout.svelte | 17 ++++++++++++++++- tests/session.test.ts | 7 +++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 412bb8ad..9877ec19 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -2,6 +2,16 @@ import '../app.pcss'; import Button from '$lib/components/ui/button/button.svelte'; import Separator from '$lib/components/ui/separator/separator.svelte'; + import { onMount } from 'svelte'; + + let newSessionId: string; + + function createNewSession() { + // Example: `zbvxte` + newSessionId = Math.random().toString(36).substring(2, 8); + } + + onMount(createNewSession);

@@ -12,7 +22,12 @@
-
diff --git a/tests/session.test.ts b/tests/session.test.ts index 6d586de5..5dd7344e 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -70,6 +70,13 @@ test('creates new session and chats', async ({ page }) => { })).toBeVisible(); await expect(page.getByText('AI')).toHaveCount(2); expect(await page.getByText('You').count()).toBeGreaterThan(1); + + // Check the session is saved to localStorage + await page.getByText('New session').click(); + await expect(page.locator('article')).toHaveCount(0); + + await page.goBack(); + await expect(page.locator('article')).toHaveCount(4); }); test.skip('handles API error when generating AI response', async ({ page }) => { From b4b8ef5f27f7d79b5500ad3a8bc7053a4681ad37 Mon Sep 17 00:00:00 2001 From: Fernando Maclen Date: Wed, 10 Apr 2024 20:05:03 -0400 Subject: [PATCH 13/15] add one more test --- tests/session.test.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/session.test.ts b/tests/session.test.ts index 5dd7344e..d633619a 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -79,6 +79,26 @@ test('creates new session and chats', async ({ page }) => { await expect(page.locator('article')).toHaveCount(4); }); +test('generates a random session id', async ({ page }) => { + await page.goto('/'); + const newSessionButton = page.getByText('New session'); + const sessionId1 = await newSessionButton.getAttribute('href'); + expect(sessionId1).toMatch(/[a-z0-9]{2,8}/); + + await newSessionButton.click(); + const sessionId2 = await newSessionButton.getAttribute('href'); + expect(sessionId2).toMatch(/[a-z0-9]{2,8}/); + + expect(sessionId1).not.toEqual(sessionId2); + + await newSessionButton.click(); + const sessionId3 = await newSessionButton.getAttribute('href'); + expect(sessionId3).toMatch(/[a-z0-9]{2,8}/); + + expect(sessionId1).not.toEqual(sessionId3); + expect(sessionId2).not.toEqual(sessionId3); +}); + test.skip('handles API error when generating AI response', async ({ page }) => { // TODO: Implement the test }); From 86730248a195f0757cbcd21fd189b26f3624396b Mon Sep 17 00:00:00 2001 From: Fernando Maclen Date: Wed, 10 Apr 2024 20:14:09 -0400 Subject: [PATCH 14/15] test: update "generates a random session id" --- tests/session.test.ts | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/tests/session.test.ts b/tests/session.test.ts index d633619a..67725cc9 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -81,22 +81,19 @@ test('creates new session and chats', async ({ page }) => { test('generates a random session id', async ({ page }) => { await page.goto('/'); - const newSessionButton = page.getByText('New session'); - const sessionId1 = await newSessionButton.getAttribute('href'); - expect(sessionId1).toMatch(/[a-z0-9]{2,8}/); - - await newSessionButton.click(); - const sessionId2 = await newSessionButton.getAttribute('href'); - expect(sessionId2).toMatch(/[a-z0-9]{2,8}/); - expect(sessionId1).not.toEqual(sessionId2); + const sessionIds = []; + const newSessionButton = page.getByText('New session'); - await newSessionButton.click(); - const sessionId3 = await newSessionButton.getAttribute('href'); - expect(sessionId3).toMatch(/[a-z0-9]{2,8}/); + // Check it generates a new session id 3 times in a row + for (let i = 0; i < 3; i++) { + const sessionId = await newSessionButton.getAttribute('href'); + expect(sessionId).toMatch(/[a-z0-9]{2,8}/); + sessionIds.push(sessionId); + await newSessionButton.click(); + } - expect(sessionId1).not.toEqual(sessionId3); - expect(sessionId2).not.toEqual(sessionId3); + expect(new Set(sessionIds).size).toBe(3); }); test.skip('handles API error when generating AI response', async ({ page }) => { From a2c65fe8ab31f89202eeca153c71497301ee69f5 Mon Sep 17 00:00:00 2001 From: Fernando Maclen Date: Wed, 10 Apr 2024 20:15:28 -0400 Subject: [PATCH 15/15] test: DRY "creates new session and chats" --- tests/session.test.ts | 68 +++++++++++++++++++++---------------------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/tests/session.test.ts b/tests/session.test.ts index 67725cc9..bf0afb3b 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -14,27 +14,33 @@ test.beforeEach(async ({ page }) => { }); test('creates new session and chats', async ({ page }) => { + const modelTrigger = page.locator('button[data-melt-select-trigger]'); + const sessionIdLocator = page.getByTestId('session-id'); + const modelNameLocator = page.getByTestId('model-name'); + const newSessionButton = page.getByText('New session'); + const promptTextarea = page.getByPlaceholder('Prompt'); + const sendButton = page.getByText('Send'); + const articleLocator = page.locator('article'); + await page.goto('/'); - await page.click('button[data-melt-select-trigger]'); // Open model list - await page.click('div[role="option"]:has-text("gemma:7b")'); // Select model - await expect(page.getByTestId('session-id')).not.toBeVisible(); - await expect(page.getByTestId('model-name')).not.toBeVisible(); - - await page.getByText('New session').click(); - await expect(page.getByTestId('session-id')).toBeVisible(); - await expect(page.getByTestId('session-id')).toHaveText(/Session #[a-z0-9]{2,8}/); - await expect(page.getByTestId('model-name')).toBeVisible(); - await expect(page.getByTestId('model-name')).toHaveText('gemma:7b'); - await expect(page.getByPlaceholder('Prompt')).toBeVisible(); - await expect(page.getByPlaceholder('Prompt')).toHaveText(''); - await expect(page.getByText('Send')).toBeVisible(); - await expect(page.getByText('Send')).toBeDisabled(); - - await page.getByPlaceholder('Prompt').fill('Who would win in a fight between Emma Watson and Jessica Alba?'); - await expect(page.getByText('Send')).not.toBeDisabled(); - await expect(page.locator('article', { - hasText: 'I am unable to provide subjective or speculative information, including fight outcomes between individuals.' - })).not.toBeVisible(); + await modelTrigger.click(); + await page.click('div[role="option"]:has-text("gemma:7b")'); + await expect(sessionIdLocator).not.toBeVisible(); + await expect(modelNameLocator).not.toBeVisible(); + + await newSessionButton.click(); + await expect(sessionIdLocator).toBeVisible(); + await expect(sessionIdLocator).toHaveText(/Session #[a-z0-9]{2,8}/); + await expect(modelNameLocator).toBeVisible(); + await expect(modelNameLocator).toHaveText('gemma:7b'); + await expect(promptTextarea).toBeVisible(); + await expect(promptTextarea).toHaveValue(''); + await expect(sendButton).toBeVisible(); + await expect(sendButton).toBeDisabled(); + + await promptTextarea.fill('Who would win in a fight between Emma Watson and Jessica Alba?'); + await expect(sendButton).toBeEnabled(); + await expect(page.locator('article', { hasText: 'I am unable to provide subjective or speculative information, including fight outcomes between individuals.' })).not.toBeVisible(); await page.route('**/generate', async (route) => { await route.fulfill({ @@ -44,15 +50,11 @@ test('creates new session and chats', async ({ page }) => { }); }); - await page.getByText('Send').click(); - await expect(page.locator('article', { - hasText: 'I am unable to provide subjective or speculative information, including fight outcomes between individuals.' - })).toBeVisible(); + await sendButton.click(); + await expect(page.locator('article', { hasText: 'I am unable to provide subjective or speculative information, including fight outcomes between individuals.' })).toBeVisible(); - await page.getByPlaceholder('Prompt').fill("I understand, it's okay"); - await expect(page.locator('article', { - hasText: 'No problem! If you have any other questions or would like to discuss something else, feel free to ask' - })).not.toBeVisible(); + await promptTextarea.fill("I understand, it's okay"); + await expect(page.locator('article', { hasText: 'No problem! If you have any other questions or would like to discuss something else, feel free to ask' })).not.toBeVisible(); await expect(page.locator('article', { hasText: "I understand, it's okay" })).not.toBeVisible(); await page.route('**/generate', async (route) => { @@ -65,18 +67,16 @@ test('creates new session and chats', async ({ page }) => { await page.keyboard.press('Enter'); await expect(page.locator('article', { hasText: "I understand, it's okay" })).toBeVisible(); - await expect(page.locator('article', { - hasText: 'No problem! If you have any other questions or would like to discuss something else, feel free to ask' - })).toBeVisible(); + await expect(page.locator('article', { hasText: 'No problem! If you have any other questions or would like to discuss something else, feel free to ask' })).toBeVisible(); await expect(page.getByText('AI')).toHaveCount(2); expect(await page.getByText('You').count()).toBeGreaterThan(1); // Check the session is saved to localStorage - await page.getByText('New session').click(); - await expect(page.locator('article')).toHaveCount(0); + await newSessionButton.click(); + await expect(articleLocator).toHaveCount(0); await page.goBack(); - await expect(page.locator('article')).toHaveCount(4); + await expect(articleLocator).toHaveCount(4); }); test('generates a random session id', async ({ page }) => {