diff --git a/.github/workflows/embeddings-ui.yml b/.github/workflows/embeddings-ui.yml new file mode 100644 index 000000000..a23388256 --- /dev/null +++ b/.github/workflows/embeddings-ui.yml @@ -0,0 +1,49 @@ +name: Embeddings UI + +on: + pull_request: + types: + - opened + - synchronize + - reopened + paths: + - 'src/app/(dashboard)/(modules)/embeddings/**' + - 'src/app/(dashboard)/template.tsx' + - 'src/components/embeddings/**' + - 'src/components/dashboard/ActiveModuleGrid.tsx' + - 'src/components/dashboard/ModuleDashboard.tsx' + - 'src/components/navigation/navList.config.ts' + - 'src/lib/models/embeddings/**' + - 'src/lib/api/embeddings/**' + - 'src/lib/api/database/**' + - 'src/lib/utils/module-utils.ts' + - 'src/lib/utils/module-utils.test.ts' + - 'src/lib/utils/breadcrumbs.ts' + - 'src/lib/utils/breadcrumbs.test.ts' + - 'src/test/**' + - 'e2e/**' + - 'playwright.config.ts' + - 'package.json' + - 'pnpm-lock.yaml' + - 'vitest.config.mts' + - '.github/workflows/embeddings-ui.yml' + +jobs: + unit-lint-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + - name: Enable Corepack + run: corepack enable + - run: pnpm install --frozen-lockfile + - run: pnpm test + - run: pnpm lint + - run: pnpm exec tsc --noEmit + - run: CI=false pnpm build + - name: Install Playwright Chromium + run: pnpm exec playwright install --with-deps chromium + - run: pnpm test:e2e diff --git a/.gitignore b/.gitignore index 6d265d339..318646acf 100644 --- a/.gitignore +++ b/.gitignore @@ -131,3 +131,10 @@ dist # typescript next-env.d.ts + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/e2e/.auth/ diff --git a/e2e/auth.setup.ts b/e2e/auth.setup.ts new file mode 100644 index 000000000..b27263ff8 --- /dev/null +++ b/e2e/auth.setup.ts @@ -0,0 +1,11 @@ +import { test as setup } from '@playwright/test'; +import { loginThroughUi } from './helpers/ui.ts'; +import { resetMock } from './helpers/mock.ts'; + +const storagePath = 'e2e/.auth/user.json'; + +setup('authenticate through login', async ({ page }) => { + await resetMock('ready'); + await loginThroughUi(page); + await page.context().storageState({ path: storagePath }); +}); diff --git a/e2e/backfills.spec.ts b/e2e/backfills.spec.ts new file mode 100644 index 000000000..f95edc94d --- /dev/null +++ b/e2e/backfills.spec.ts @@ -0,0 +1,96 @@ +import { expect, test } from '@playwright/test'; +import { resetMock } from './helpers/mock.ts'; +import { exactText } from './helpers/ui.ts'; + +test.describe('embedding backfills', () => { + test('starts a run and advances queued to running to completed', async ({ + page, + }) => { + await resetMock('ready'); + await page.goto('/embeddings/backfills'); + await page.getByRole('button', { name: 'Start backfill' }).click(); + await expect(page.getByRole('dialog')).toBeVisible(); + await page.locator('#start-schema').click(); + await page.getByRole('option', { name: 'Product' }).click(); + await page + .getByRole('dialog') + .getByRole('button', { name: 'Start backfill' }) + .click(); + await expect(exactText(page, 'Backfill queued')).toBeVisible(); + await expect(page.getByRole('dialog')).toBeHidden(); + await page.getByRole('link', { name: 'Product' }).first().click(); + await expect(exactText(page, 'Queued')).toBeVisible(); + await expect(exactText(page, 'Running')).toBeVisible({ timeout: 15_000 }); + await expect(exactText(page, 'Completed')).toBeVisible({ + timeout: 15_000, + }); + }); + + test('cancels a run and resumes it', async ({ page }) => { + await resetMock('ready'); + await page.goto('/embeddings/backfills'); + await page.getByRole('button', { name: 'Start backfill' }).click(); + await page.locator('#start-schema').click(); + await page.getByRole('option', { name: 'Product' }).click(); + await page + .getByRole('dialog') + .getByRole('button', { name: 'Start backfill' }) + .click(); + await expect(exactText(page, 'Backfill queued')).toBeVisible(); + await expect(page.getByRole('dialog')).toBeHidden(); + await page.getByRole('link', { name: 'Product' }).first().click(); + await page.getByRole('button', { name: 'Cancel run' }).click(); + await expect(page.getByRole('alertdialog')).toBeVisible(); + await page + .getByRole('alertdialog') + .getByRole('button', { name: 'Cancel run' }) + .click(); + await expect(exactText(page, 'Backfill canceled')).toBeVisible(); + await expect(exactText(page, 'Canceled').first()).toBeVisible(); + await page.getByRole('button', { name: 'Resume run' }).click(); + await expect(page.getByRole('alertdialog')).toBeVisible(); + await page + .getByRole('alertdialog') + .getByRole('button', { name: 'Resume run' }) + .click(); + await expect(exactText(page, 'Backfill resumed')).toBeVisible(); + await expect(exactText(page, 'Queued')).toBeVisible(); + }); + + test('cancels and resumes from the list row', async ({ page }) => { + await resetMock('ready'); + await page.goto('/embeddings/backfills'); + await page.getByRole('button', { name: 'Start backfill' }).click(); + await page.locator('#start-schema').click(); + await page.getByRole('option', { name: 'Product' }).click(); + await page + .getByRole('dialog') + .getByRole('button', { name: 'Start backfill' }) + .click(); + await expect(exactText(page, 'Backfill queued')).toBeVisible(); + await expect(page.getByRole('dialog')).toBeHidden(); + await page.getByRole('button', { name: 'Cancel Product run' }).click(); + await expect(page.getByRole('alertdialog')).toBeVisible(); + await page + .getByRole('alertdialog') + .getByRole('button', { name: 'Cancel run' }) + .click(); + await expect(exactText(page, 'Backfill canceled')).toBeVisible(); + await page.getByRole('button', { name: 'Resume Product run' }).click(); + await expect(page.getByRole('alertdialog')).toBeVisible(); + await page + .getByRole('alertdialog') + .getByRole('button', { name: 'Resume run' }) + .click(); + await expect(exactText(page, 'Backfill resumed')).toBeVisible(); + await expect(exactText(page, 'Queued')).toBeVisible(); + }); + + test('disables next page when the list is empty', async ({ page }) => { + await resetMock('blank'); + await page.goto('/embeddings/backfills'); + await expect( + page.getByRole('button', { name: 'Go to next page' }) + ).toBeDisabled(); + }); +}); diff --git a/e2e/configs.spec.ts b/e2e/configs.spec.ts new file mode 100644 index 000000000..4eb6dcf59 --- /dev/null +++ b/e2e/configs.spec.ts @@ -0,0 +1,220 @@ +import { expect, test } from '@playwright/test'; +import { resetMock } from './helpers/mock.ts'; + +test.describe('embedding configs', () => { + test('creates a config and shows a pending matching index', async ({ + page, + }) => { + await resetMock('blank'); + await page.goto('/embeddings/configs/new'); + await expect( + page.getByRole('heading', { name: 'New config' }) + ).toBeVisible(); + await page.getByRole('combobox', { name: 'Schema' }).click(); + await page.getByRole('option', { name: 'Product' }).click(); + await page.getByRole('checkbox', { name: 'title' }).click(); + await page.getByLabel('Target field').fill('embedding'); + await page.getByRole('button', { name: 'Create config' }).click(); + await expect(page).toHaveURL(/\/embeddings\/configs\/cfg_/); + await expect( + page.getByRole('heading', { name: 'Matching index' }) + ).toBeVisible(); + await expect(page.getByText('Pending', { exact: true })).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'Index is not queryable yet' }) + ).toBeVisible(); + await expect( + page.getByText('Not queryable', { exact: true }) + ).toBeVisible(); + }); + + test('omits disabled, non-extendable, and internal schemas from create choices', async ({ + page, + }) => { + await resetMock('blank'); + await page.goto('/embeddings/configs/new'); + await page.getByRole('combobox', { name: 'Schema' }).click(); + await expect(page.getByRole('option', { name: 'Product' })).toBeVisible(); + await expect(page.getByRole('option', { name: 'User' })).toBeVisible(); + await expect(page.getByRole('option', { name: 'Team' })).toBeVisible(); + await expect(page.getByRole('option', { name: 'Note' })).toBeVisible(); + await expect( + page.getByRole('option', { name: 'ArchivedProduct' }) + ).toHaveCount(0); + await expect(page.getByRole('option', { name: 'CmsOnly' })).toHaveCount(0); + await expect( + page.getByRole('option', { name: 'Admin', exact: true }) + ).toHaveCount(0); + await expect( + page.getByRole('option', { name: 'AdminMiddleware' }) + ).toHaveCount(0); + await expect( + page.getByRole('option', { name: 'AppMiddleware' }) + ).toHaveCount(0); + await expect(page.getByRole('option', { name: 'Client' })).toHaveCount(0); + await expect(page.getByRole('option', { name: 'Config' })).toHaveCount(0); + await expect(page.getByRole('option', { name: 'Views' })).toHaveCount(0); + await page.getByPlaceholder('Search schemas').fill('cms'); + await expect(page.getByText('No matching schemas')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(page.getByPlaceholder('Search schemas')).toHaveCount(0); + }); + + test('asks for a schema before listing source fields', async ({ page }) => { + await resetMock('blank'); + await page.goto('/embeddings/configs/new'); + await expect( + page.getByText('Select a schema to see eligible fields.') + ).toBeVisible(); + }); + + test('uses route labels instead of raw config ids in breadcrumbs', async ({ + page, + }) => { + await resetMock('ready'); + await page.goto('/embeddings/configs/cfg_product'); + const crumbs = page.getByRole('navigation', { name: 'breadcrumb' }); + await expect(crumbs.getByText('Configs', { exact: true })).toBeVisible(); + await expect(crumbs.getByText('Config', { exact: true })).toBeVisible(); + await expect(crumbs.getByText('Cfg_product')).toHaveCount(0); + }); + + test('shows stacked config cards on a narrow viewport', async ({ page }) => { + await resetMock('ready'); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/embeddings/configs'); + await expect(page.getByRole('heading', { name: 'Configs' })).toBeVisible(); + await expect( + page.getByRole('term').filter({ hasText: 'Index' }).first() + ).toBeVisible(); + await expect( + page + .getByRole('definition') + .filter({ hasText: 'openai-compatible/text-embedding-3-small' }) + .first() + ).toBeVisible(); + await expect( + page.getByRole('definition').filter({ hasText: 'Ready' }).first() + ).toBeVisible(); + }); + + test('selects the highest ready generation over a pending v1', async ({ + page, + }) => { + await resetMock('ready'); + await page.goto('/embeddings/configs/cfg_product'); + await expect(page.getByText('Product_embedding_v2')).toBeVisible(); + await expect(page.getByText('Product_embedding_v1')).toHaveCount(0); + const generation = page.getByRole('term').filter({ hasText: 'Generation' }); + await expect(generation).toBeVisible(); + await expect( + generation.locator('xpath=following-sibling::dd[1]') + ).toHaveText('2'); + }); + + test('searches schemas with arrows and enter', async ({ page }) => { + await resetMock('blank'); + await page.goto('/embeddings/configs/new'); + const schema = page.getByRole('combobox', { name: 'Schema' }); + await schema.click(); + await page.getByPlaceholder('Search schemas').fill('pro'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Enter'); + await expect(schema).toContainText('Product'); + }); + + test('derives dimensions from the selected model and clears on provider change', async ({ + page, + }) => { + await resetMock('blank'); + await page.goto('/embeddings/configs/new'); + await expect(page.getByLabel('Dimensions')).toHaveValue('1536'); + await expect(page.getByLabel('Dimensions')).toHaveAttribute('readonly', ''); + await expect(page.getByText(/Recommended for text/)).toBeVisible(); + await page.getByRole('combobox', { name: 'Provider' }).click(); + await page.getByRole('option', { name: 'voyage' }).click(); + await expect(page.getByRole('combobox', { name: 'Model' })).toContainText( + 'Select a model' + ); + await page.getByRole('combobox', { name: 'Model' }).click(); + await page.getByRole('option', { name: 'voyage-3' }).click(); + await expect(page.getByLabel('Dimensions')).toHaveValue('1024'); + }); + + test('keeps the only provider visible and disabled on an existing config', async ({ + page, + }) => { + await resetMock('ready'); + await page.goto('/embeddings/configs/cfg_product'); + const provider = page.getByRole('combobox', { name: 'Provider' }); + await expect(provider).toBeVisible(); + await expect(provider).toBeDisabled(); + await expect(provider).toContainText('openai-compatible'); + }); + + test('rejects an incompatible target field collision', async ({ page }) => { + await resetMock('blank'); + await page.goto('/embeddings/configs/new'); + await page.getByRole('combobox', { name: 'Schema' }).click(); + await page.getByRole('option', { name: 'Note' }).click(); + await page.getByRole('checkbox', { name: 'title' }).click(); + await page.getByLabel('Target field').fill('embedding'); + await page.getByRole('button', { name: 'Create config' }).click(); + await expect( + page + .getByRole('region', { name: 'Notifications (F8)' }) + .getByText( + "Field 'embedding' already exists on schema 'Note' and is not a compatible embeddings extension" + ) + ).toBeVisible(); + }); + + test('resets enabled after a material edit that stays pending', async ({ + page, + }) => { + await resetMock('ready'); + await page.goto('/embeddings/configs/cfg_product'); + const enabled = page.getByRole('switch', { name: 'Enabled' }); + await expect(enabled).toBeChecked(); + await page.getByRole('combobox', { name: 'Similarity' }).click(); + await page.getByRole('option', { name: 'Euclidean' }).click(); + await page.getByRole('button', { name: /Save changes/ }).click(); + const dialog = page.getByRole('alertdialog'); + await expect(dialog).toBeVisible(); + await expect( + dialog.getByRole('heading', { name: 'Save material changes?' }) + ).toBeVisible(); + await dialog.getByRole('button', { name: 'Save changes' }).click(); + await expect( + page + .getByRole('region', { name: 'Notifications (F8)' }) + .getByText( + 'Config stayed disabled until the matching index is queryable.' + ) + ).toBeVisible(); + await expect(enabled).not.toBeChecked(); + await expect( + page.getByRole('button', { name: 'Save changes', exact: true }) + ).toBeDisabled(); + await expect(page.getByText('Pending', { exact: true })).toBeVisible(); + }); + + test('blocks a config whose model is absent from the catalogue', async ({ + page, + }) => { + await resetMock('ready'); + await page.goto('/embeddings/configs/cfg_legacy'); + await expect( + page.getByText( + 'This model is not in the provider catalogue. Add it in Settings before changing this config.' + ) + ).toBeVisible(); + await expect( + page.getByRole('link', { name: 'Open settings' }).first() + ).toHaveAttribute('href', '/embeddings/settings'); + await expect(page.getByRole('combobox', { name: 'Model' })).toContainText( + 'text-embedding-ada-002' + ); + await expect(page.getByRole('combobox', { name: 'Model' })).toBeDisabled(); + }); +}); diff --git a/e2e/helpers/mock.ts b/e2e/helpers/mock.ts new file mode 100644 index 000000000..2ecb5a71b --- /dev/null +++ b/e2e/helpers/mock.ts @@ -0,0 +1,58 @@ +import { + E2E_TEST_CONTROL_HEADER, + E2E_TEST_CONTROL_TOKEN, + MOCK_ADMIN_ORIGIN, +} from '../mock-admin/constants.ts'; +import type { MockScenario } from '../mock-admin/types.ts'; + +function testControlHeaders( + extra?: Record +): Record { + return { + [E2E_TEST_CONTROL_HEADER]: E2E_TEST_CONTROL_TOKEN, + ...extra, + }; +} + +export async function resetMock( + scenario: MockScenario = 'ready' +): Promise { + const response = await fetch(`${MOCK_ADMIN_ORIGIN}/__test__/reset`, { + method: 'POST', + headers: testControlHeaders({ 'content-type': 'application/json' }), + body: JSON.stringify({ scenario }), + }); + if (!response.ok) { + throw new Error(`Mock reset failed: ${response.status}`); + } +} + +export async function revokeMockTokens(): Promise { + const response = await fetch(`${MOCK_ADMIN_ORIGIN}/__test__/revoke`, { + method: 'POST', + headers: testControlHeaders(), + }); + if (!response.ok) { + throw new Error(`Mock revoke failed: ${response.status}`); + } +} + +export type MockInspectState = { + scenario: string; + configCount: number; + backfillCount: number; + storedApiKeyConfigured: boolean; + lastSettingsPatchHadApiKey: boolean; + workersEnabled: boolean; + modules: string[]; +}; + +export async function inspectMock(): Promise { + const response = await fetch(`${MOCK_ADMIN_ORIGIN}/__test__/state`, { + headers: testControlHeaders(), + }); + if (!response.ok) { + throw new Error(`Mock inspect failed: ${response.status}`); + } + return (await response.json()) as MockInspectState; +} diff --git a/e2e/helpers/ui.ts b/e2e/helpers/ui.ts new file mode 100644 index 000000000..d8ae80049 --- /dev/null +++ b/e2e/helpers/ui.ts @@ -0,0 +1,47 @@ +import { expect, type Page } from '@playwright/test'; +import { E2E_PASSWORD, E2E_USERNAME } from '../mock-admin/constants.ts'; + +export async function loginThroughUi(page: Page): Promise { + await page.goto('/login'); + await page.getByPlaceholder('username').fill(E2E_USERNAME); + await page.getByPlaceholder('******').fill(E2E_PASSWORD); + await page.getByRole('button', { name: 'Sign In' }).click(); + await page.waitForURL(url => !url.pathname.startsWith('/login')); +} + +export async function openEmbeddingsNav(page: Page): Promise { + const link = page.locator('a[href="/embeddings"]').first(); + await expect(link).toBeVisible(); +} + +export async function expectNoEmbeddingsNav(page: Page): Promise { + await expect(page.locator('a[href="/embeddings"]')).toHaveCount(0); +} + +export function exactText(page: Page, text: string) { + return page.getByText(text, { exact: true }); +} + +export async function dismissDevOverlay(page: Page): Promise { + await page.evaluate(() => { + document.querySelectorAll('nextjs-portal').forEach(node => node.remove()); + }); +} + +export async function switchToDarkTheme(page: Page): Promise { + await dismissDevOverlay(page); + const viewport = page.viewportSize(); + if (viewport && viewport.width < 768) { + await page.keyboard.press('ControlOrMeta+b'); + await page + .getByRole('dialog') + .getByRole('button', { name: 'a', exact: true }) + .click(); + } else { + await page.getByRole('button', { name: 'a', exact: true }).click({ + force: true, + }); + } + await page.getByText('Theme', { exact: true }).click(); + await page.getByRole('menuitemcheckbox', { name: 'Dark' }).click(); +} diff --git a/e2e/keyboard.spec.ts b/e2e/keyboard.spec.ts new file mode 100644 index 000000000..04f1d0be8 --- /dev/null +++ b/e2e/keyboard.spec.ts @@ -0,0 +1,66 @@ +import { expect, test } from '@playwright/test'; +import { resetMock } from './helpers/mock.ts'; +import { exactText } from './helpers/ui.ts'; + +test.describe('keyboard', () => { + test('closes the start-backfill dialog with Escape', async ({ page }) => { + await resetMock('ready'); + await page.goto('/embeddings/backfills'); + await page.getByRole('button', { name: 'Start backfill' }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(dialog).toBeHidden(); + }); + + test('submits Test Search with Enter', async ({ page }) => { + await resetMock('ready'); + await page.goto('/embeddings/test'); + await page.getByLabel('Query').fill('published guide'); + await page.getByLabel('Query').press('Enter'); + await expect( + page.getByRole('cell', { name: 'Published guide' }) + ).toBeVisible(); + }); + + test('saves settings with the platform save shortcut', async ({ page }) => { + await resetMock('ready'); + await page.goto('/embeddings/settings'); + await page.getByRole('button', { name: 'Edit' }).click(); + await page.getByLabel('Dimensions').fill('3072'); + await page.keyboard.press('ControlOrMeta+s'); + await expect(exactText(page, 'Embeddings settings saved')).toBeVisible(); + }); + + test('searches schemas and models with arrows and enter', async ({ + page, + }) => { + await resetMock('blank'); + await page.goto('/embeddings/configs/new'); + const schema = page.getByRole('combobox', { name: 'Schema' }); + await schema.click(); + await page.getByPlaceholder('Search schemas').fill('pro'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Enter'); + await expect(schema).toContainText('Product'); + + const model = page.getByRole('combobox', { name: 'Model' }); + await model.click(); + await page.getByPlaceholder('Search models').fill('large'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Enter'); + await expect(model).toContainText('text-embedding-3-large'); + await expect(page.getByLabel('Dimensions')).toHaveValue('3072'); + }); + + test('keeps a visible focus ring on the Test Search submit control', async ({ + page, + }) => { + await resetMock('ready'); + await page.goto('/embeddings/test'); + await page.getByLabel('Query').fill('published guide'); + const search = page.getByRole('button', { name: 'Search' }); + await search.focus(); + await expect(search).toBeFocused(); + }); +}); diff --git a/e2e/mock-admin/constants.ts b/e2e/mock-admin/constants.ts new file mode 100644 index 000000000..2bf82f9f8 --- /dev/null +++ b/e2e/mock-admin/constants.ts @@ -0,0 +1,60 @@ +export const MOCK_ADMIN_HOST = '127.0.0.1'; +export const MOCK_ADMIN_PORT = 4010; +export const MOCK_ADMIN_ORIGIN = `http://${MOCK_ADMIN_HOST}:${MOCK_ADMIN_PORT}`; + +export const E2E_MASTER_KEY = 'e2e-master-key'; +export const E2E_USERNAME = 'admin'; +export const E2E_PASSWORD = 'admin'; +export const E2E_ENV_NAME = 'Local'; +export const E2E_TEST_CONTROL_HEADER = 'x-conduit-e2e-control'; +export const E2E_TEST_CONTROL_TOKEN = 'e2e-control-token'; +export const JWT_SECRET = 'e2e-jwt-secret'; + +export const REDACTED_SECRET = '[REDACTED]'; +export const STORED_API_KEY = 'sk-e2e-stored-key'; +export const OPENAI_COMPATIBLE_PROVIDER = 'openai-compatible'; +export const PROVIDER_ENDPOINT = 'https://api.openai.com/v1/embeddings'; +export const PROVIDER_MODEL = 'text-embedding-3-small'; +export const PROVIDER_DIMENSIONS = 1536; +export const SECOND_PROVIDER = 'voyage'; +export const SECOND_PROVIDER_MODEL = 'voyage-3'; +export const SECOND_PROVIDER_DIMENSIONS = 1024; + +export const PRODUCT_SCHEMA_ID = 'schema_product'; +export const PRODUCT_SCHEMA_NAME = 'Product'; +export const ARCHIVED_SCHEMA_ID = 'schema_archived'; +export const ARCHIVED_SCHEMA_NAME = 'ArchivedProduct'; +export const CMS_ONLY_SCHEMA_ID = 'schema_cms_only'; +export const CMS_ONLY_SCHEMA_NAME = 'CmsOnly'; +export const COLLISION_SCHEMA_ID = 'schema_note'; +export const COLLISION_SCHEMA_NAME = 'Note'; +export const USER_SCHEMA_ID = 'schema_user'; +export const USER_SCHEMA_NAME = 'User'; +export const TEAM_SCHEMA_ID = 'schema_team'; +export const TEAM_SCHEMA_NAME = 'Team'; +export const ADMIN_SCHEMA_ID = 'schema_admin'; +export const ADMIN_SCHEMA_NAME = 'Admin'; +export const ADMIN_MIDDLEWARE_SCHEMA_ID = 'schema_admin_middleware'; +export const ADMIN_MIDDLEWARE_SCHEMA_NAME = 'AdminMiddleware'; +export const APP_MIDDLEWARE_SCHEMA_ID = 'schema_app_middleware'; +export const APP_MIDDLEWARE_SCHEMA_NAME = 'AppMiddleware'; +export const CLIENT_SCHEMA_ID = 'schema_client'; +export const CLIENT_SCHEMA_NAME = 'Client'; +export const CONFIG_SCHEMA_ID = 'schema_config'; +export const CONFIG_SCHEMA_NAME = 'Config'; +export const VIEWS_SCHEMA_ID = 'schema_views'; +export const VIEWS_SCHEMA_NAME = 'Views'; +export const DATABASE_SYSTEM_SCHEMAS = [ + '_DeclaredSchema', + 'MigratedSchemas', + 'CustomEndpoints', + 'PendingSchemas', + 'Views', +] as const; +export const READY_CONFIG_ID = 'cfg_product'; +export const LEGACY_CONFIG_ID = 'cfg_legacy'; +export const LEGACY_MODEL = 'text-embedding-ada-002'; +export const READY_INDEX_NAME = 'Product_embedding_v2'; +export const PENDING_INDEX_NAME = 'Product_embedding_v1'; + +export const FIXED_NOW = '2026-01-15T12:00:00.000Z'; diff --git a/e2e/mock-admin/handlers.ts b/e2e/mock-admin/handlers.ts new file mode 100644 index 000000000..d57148a1e --- /dev/null +++ b/e2e/mock-admin/handlers.ts @@ -0,0 +1,1155 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { + DATABASE_SYSTEM_SCHEMAS, + E2E_MASTER_KEY, + E2E_PASSWORD, + E2E_TEST_CONTROL_HEADER, + E2E_TEST_CONTROL_TOKEN, + E2E_USERNAME, + FIXED_NOW, + OPENAI_COMPATIBLE_PROVIDER, + PRODUCT_SCHEMA_ID, + REDACTED_SECRET, +} from './constants.ts'; +import { signAdminToken } from './jwt.ts'; +import { + bearerToken, + isRecord, + masterKey, + readBoolean, + readJsonBody, + readNumber, + readString, + readStringArray, + requestPath, + sendEmpty, + sendJson, +} from './http.ts'; +import { + cloneRun, + emptyQueueCounts, + getState, + registerIssuedToken, + resetState, + revokeActiveTokens, + toApiConfig, + toApiRun, +} from './state.ts'; +import { isMockScenario } from './types.ts'; +import type { + MockBackfillRun, + MockEmbeddingConfig, + MockEmbeddingsSettings, + MockProviderSettings, + MockSchema, +} from './types.ts'; + +function unauthorized(response: ServerResponse): void { + sendJson(response, 401, { status: 401, message: 'Unauthorized' }); +} + +function notFound(response: ServerResponse, message = 'Not found'): void { + sendJson(response, 404, { status: 404, message }); +} + +function badRequest(response: ServerResponse, message: string): void { + sendJson(response, 400, { status: 400, message }); +} + +function isPublicPath(pathname: string, method: string): boolean { + if (pathname === '/ready') return true; + return method === 'POST' && pathname === '/login'; +} + +function hasTestControlHeader(request: IncomingMessage): boolean { + const value = request.headers[E2E_TEST_CONTROL_HEADER]; + const token = Array.isArray(value) ? value[0] : value; + return token === E2E_TEST_CONTROL_TOKEN; +} + +function requireAuth( + request: IncomingMessage, + response: ServerResponse, + pathname: string, + method: string +): boolean { + if (masterKey(request) !== E2E_MASTER_KEY) { + unauthorized(response); + return false; + } + if (isPublicPath(pathname, method)) return true; + const token = bearerToken(request); + if (!token || !getState().tokens.has(token)) { + unauthorized(response); + return false; + } + return true; +} + +function toApiSettings( + settings: MockEmbeddingsSettings +): MockEmbeddingsSettings { + const providers: Record = {}; + for (const [name, provider] of Object.entries(settings.providers)) { + const storedKey = provider.apiKey; + const normalized = normalizeMockProvider(provider); + providers[name] = { + endpoint: normalized.endpoint, + apiKey: storedKey ? REDACTED_SECRET : '', + models: normalized.models.map(model => ({ ...model })), + defaultModel: normalized.defaultModel, + }; + } + const security = { ...settings.security }; + return { + enabled: settings.enabled, + defaultProvider: settings.defaultProvider, + providers, + queue: { ...settings.queue }, + security, + }; +} + +function isRecordOfProviders(value: unknown): value is Record { + return isRecord(value); +} + +function parseMockModels( + value: unknown +): MockProviderSettings['models'] | undefined { + if (!Array.isArray(value)) return undefined; + const models: MockProviderSettings['models'] = []; + const names = new Set(); + for (const [index, item] of value.entries()) { + if (!isRecord(item)) { + throw new Error(`Provider model at index ${index} is invalid`); + } + const name = readString(item.name)?.trim(); + const dimensions = readNumber(item.dimensions); + if (!name) { + throw new Error( + `Provider model at index ${index} must have a non-empty name` + ); + } + if (dimensions == null || dimensions <= 0) { + throw new Error( + `Provider model '${name}' dimensions must be a positive integer` + ); + } + if (names.has(name)) { + throw new Error(`Provider model '${name}' is duplicated`); + } + names.add(name); + models.push({ name, dimensions }); + } + return models; +} + +function migrateLegacyModels(patch: Record): + | { + models: MockProviderSettings['models']; + defaultModel: string; + } + | undefined { + if (Array.isArray(patch.models) && patch.models.length > 0) return undefined; + const name = typeof patch.model === 'string' ? patch.model.trim() : ''; + if (!name) return undefined; + const dimensions = readNumber(patch.dimensions); + if (dimensions == null || dimensions <= 0) { + throw new Error( + `Provider model '${name}' dimensions must be a positive integer` + ); + } + return { models: [{ name, dimensions }], defaultModel: name }; +} + +function normalizeMockProvider( + provider: MockProviderSettings +): MockProviderSettings { + const names = new Set(provider.models.map(model => model.name)); + const defaultModel = provider.defaultModel.trim(); + return { + endpoint: provider.endpoint, + apiKey: provider.apiKey, + models: provider.models.map(model => ({ ...model })), + defaultModel: defaultModel && names.has(defaultModel) ? defaultModel : '', + }; +} + +function providerCatalogueError( + provider: MockProviderSettings +): string | undefined { + const names = new Set(); + for (const model of provider.models) { + if (!model.name.trim()) { + return 'Provider model must have a non-empty name'; + } + if (!Number.isInteger(model.dimensions) || model.dimensions <= 0) { + return `Provider model '${model.name}' dimensions must be a positive integer`; + } + if (names.has(model.name)) { + return `Provider model '${model.name}' is duplicated`; + } + names.add(model.name); + } + const defaultModel = provider.defaultModel.trim(); + if (defaultModel && !names.has(defaultModel)) { + return `Provider default model '${defaultModel}' is not in the catalogue`; + } + return undefined; +} + +function mergeProvider( + current: MockProviderSettings, + patch: unknown +): MockProviderSettings { + if (!isRecord(patch)) return current; + const next: MockProviderSettings = { + ...current, + models: current.models.map(model => ({ ...model })), + }; + if (typeof patch.endpoint === 'string') next.endpoint = patch.endpoint; + const migrated = migrateLegacyModels(patch); + const models = parseMockModels(patch.models); + if (models && models.length > 0) { + next.models = models; + } else if (migrated) { + next.models = migrated.models; + if (!next.defaultModel) next.defaultModel = migrated.defaultModel; + } + if (typeof patch.defaultModel === 'string') { + next.defaultModel = patch.defaultModel.trim(); + } else if (migrated && !next.defaultModel) { + next.defaultModel = migrated.defaultModel; + } + const apiKey = patch.apiKey; + if ( + typeof apiKey === 'string' && + apiKey.length > 0 && + apiKey !== REDACTED_SECRET + ) { + next.apiKey = apiKey; + } + return next; +} + +function applySettingsPatch( + current: MockEmbeddingsSettings, + patch: unknown +): { settings: MockEmbeddingsSettings; hadApiKey: boolean } { + if (!isRecord(patch)) return { settings: current, hadApiKey: false }; + const next: MockEmbeddingsSettings = { + ...current, + queue: { ...current.queue }, + security: { + ...current.security, + sourceFieldAllowlist: [...current.security.sourceFieldAllowlist], + }, + providers: { ...current.providers }, + }; + let hadApiKey = false; + if (typeof patch.enabled === 'boolean') next.enabled = patch.enabled; + if (typeof patch.defaultProvider === 'string') { + next.defaultProvider = patch.defaultProvider; + } + if (isRecord(patch.queue)) { + next.queue = { + concurrency: + readNumber(patch.queue.concurrency, next.queue.concurrency) ?? + next.queue.concurrency, + attempts: + readNumber(patch.queue.attempts, next.queue.attempts) ?? + next.queue.attempts, + maxBatchSize: + readNumber(patch.queue.maxBatchSize, next.queue.maxBatchSize) ?? + next.queue.maxBatchSize, + drainTimeoutMs: + readNumber(patch.queue.drainTimeoutMs, next.queue.drainTimeoutMs) ?? + next.queue.drainTimeoutMs, + }; + } + if (isRecord(patch.security)) { + next.security = { + sourceFieldAllowlist: Array.isArray(patch.security.sourceFieldAllowlist) + ? readStringArray(patch.security.sourceFieldAllowlist) + : next.security.sourceFieldAllowlist, + maxMutationEventIds: + readNumber( + patch.security.maxMutationEventIds, + next.security.maxMutationEventIds + ) ?? next.security.maxMutationEventIds, + embedTimeoutMs: + readNumber( + patch.security.embedTimeoutMs, + next.security.embedTimeoutMs + ) ?? next.security.embedTimeoutMs, + maxEmbedInputBytes: + readNumber( + patch.security.maxEmbedInputBytes, + next.security.maxEmbedInputBytes + ) ?? next.security.maxEmbedInputBytes, + maxEmbedResponseBytes: + readNumber( + patch.security.maxEmbedResponseBytes, + next.security.maxEmbedResponseBytes + ) ?? next.security.maxEmbedResponseBytes, + }; + } + if (isRecordOfProviders(patch.providers)) { + for (const [name, providerPatch] of Object.entries(patch.providers)) { + if (isRecord(providerPatch) && typeof providerPatch.apiKey === 'string') { + const key = providerPatch.apiKey; + if (key.length > 0 && key !== REDACTED_SECRET) hadApiKey = true; + } + const existing = next.providers[name] ?? { + endpoint: '', + apiKey: '', + models: [], + defaultModel: '', + }; + next.providers[name] = mergeProvider(existing, providerPatch); + const catalogueError = providerCatalogueError(next.providers[name]); + if (catalogueError) throw new Error(catalogueError); + } + } + return { settings: next, hadApiKey }; +} + +function matchingIndex( + config: MockEmbeddingConfig, + schemaId: string | undefined +) { + const state = getState(); + if (!schemaId) return undefined; + const matches = (state.indexesBySchemaId[schemaId] ?? []).filter( + index => + index.field === config.targetField && + index.dimensions === config.dimensions && + index.similarity === config.similarity && + (index.method ?? 'hnsw') === 'hnsw' + ); + if (matches.length === 0) return undefined; + const defaultName = `${config.targetField}_vector`; + return matches.reduce((best, current) => { + const bestGen = parseGeneration(best.name); + const currentGen = parseGeneration(current.name); + if (currentGen !== bestGen) { + return currentGen > bestGen ? current : best; + } + if (current.name === defaultName) return current; + if (best.name === defaultName) return best; + return best; + }); +} + +function parseGeneration(name: string): number { + if (!name) return 0; + const match = /^(.*)_v(\d+)$/.exec(name); + if (match) return Number(match[2]); + return 1; +} + +function readFilter(value: unknown): Record | undefined { + if (typeof value === 'string') { + try { + const parsed: unknown = JSON.parse(value); + return isRecord(parsed) ? parsed : undefined; + } catch { + return undefined; + } + } + if (isRecord(value)) return { ...value }; + return undefined; +} + +function isMockSchemaEnabled(schema: MockSchema): boolean { + const conduit = isRecord(schema.modelOptions.conduit) + ? schema.modelOptions.conduit + : undefined; + if (!conduit) return true; + const cms = isRecord(conduit.cms) ? conduit.cms : undefined; + if (cms == null) return true; + return cms.enabled === true; +} + +function isMockSchemaExtendable(schema: MockSchema): boolean { + const conduit = isRecord(schema.modelOptions.conduit) + ? schema.modelOptions.conduit + : undefined; + const permissions = isRecord(conduit?.permissions) + ? conduit.permissions + : undefined; + return permissions?.extendable === true; +} + +function isMockDeniedEmbeddingSchema(schema: MockSchema): boolean { + if (schema.ownerModule === 'embeddings') return true; + if (schema.name.startsWith('_')) return true; + if (schema.ownerModule === 'core' || schema.ownerModule === 'router') { + return true; + } + return DATABASE_SYSTEM_SCHEMAS.some( + name => name.toLowerCase() === schema.name.toLowerCase() + ); +} + +function canReceiveEmbeddings(schema: MockSchema): string | undefined { + if (isMockDeniedEmbeddingSchema(schema)) { + return `Schema '${schema.name}' cannot be used as an embedding source`; + } + if (!isMockSchemaEnabled(schema)) { + return `Schema '${schema.name}' is not enabled`; + } + if (!isMockSchemaExtendable(schema)) { + return `Schema '${schema.name}' is not extendable`; + } + return undefined; +} + +function fieldType(field: unknown): string | undefined { + if (typeof field === 'string') return field; + if (isRecord(field) && typeof field.type === 'string') return field.type; + return undefined; +} + +function isCompatibleEmbeddingField( + existing: unknown, + proposed: { type: string; dimensions?: number; similarity?: string } +): boolean { + if (fieldType(existing) !== proposed.type) return false; + if (proposed.type === 'Vector') { + if (!isRecord(existing)) return false; + if (existing.dimensions !== proposed.dimensions) return false; + if ( + typeof existing.similarity === 'string' && + existing.similarity !== proposed.similarity + ) { + return false; + } + return true; + } + if (proposed.type === 'String') { + if (isRecord(existing) && existing.required === true) return false; + return fieldType(existing) === 'String'; + } + return false; +} + +function embeddingExtensionCollision( + schema: MockSchema, + targetField: string, + dimensions: number, + similarity: string +): string | undefined { + const hashField = `${targetField}SourceHash`; + const proposed: Record< + string, + { + type: string; + dimensions?: number; + similarity?: string; + required?: boolean; + } + > = { + [targetField]: { type: 'Vector', dimensions, similarity }, + [hashField]: { type: 'String', required: false }, + }; + const extensions = Array.isArray(schema.extensions) + ? schema.extensions.filter(isRecord) + : []; + for (const [fieldName, definition] of Object.entries(proposed)) { + const owned = extensions.find( + extension => isRecord(extension.fields) && fieldName in extension.fields + ); + if (owned && owned.ownerModule !== 'embeddings') { + return `Field '${fieldName}' already exists on schema '${schema.name}' and is not a compatible embeddings extension`; + } + if (owned?.ownerModule === 'embeddings') { + if ( + !isCompatibleEmbeddingField( + isRecord(owned.fields) ? owned.fields[fieldName] : undefined, + definition + ) + ) { + return `Field '${fieldName}' already exists on schema '${schema.name}' and is not a compatible embeddings extension`; + } + continue; + } + if (fieldName in schema.fields) { + return `Field '${fieldName}' already exists on schema '${schema.name}' and is not a compatible embeddings extension`; + } + if (fieldName in schema.compiledFields) { + if ( + !isCompatibleEmbeddingField( + schema.compiledFields[fieldName], + definition + ) + ) { + return `Field '${fieldName}' already exists on schema '${schema.name}' and is not a compatible embeddings extension`; + } + } + } + return undefined; +} + +function resolveCatalogueModel( + settings: MockEmbeddingsSettings, + requestedProvider?: string, + requestedModel?: string, + requestedDimensions?: number +): + | { + provider: string; + model: string; + dimensions: number; + } + | { error: string } { + const providerName = + requestedProvider || settings.defaultProvider || OPENAI_COMPATIBLE_PROVIDER; + const provider = settings.providers[providerName]; + if (!provider) { + return { + error: `Embedding provider '${providerName}' is not a configured provider`, + }; + } + const selected = + requestedModel || + provider.defaultModel.trim() || + provider.models[0]?.name || + ''; + const model = provider.models.find(item => item.name === selected); + if (!model) { + return { + error: selected + ? `Model '${selected}' is not in the catalogue for this provider` + : 'Provider model catalogue has no selectable model', + }; + } + if (requestedDimensions == null || requestedDimensions === 0) { + return { + provider: providerName, + model: model.name, + dimensions: model.dimensions, + }; + } + if (requestedDimensions !== model.dimensions) { + return { + error: `Requested dimensions ${requestedDimensions} do not match catalogue dimensions ${model.dimensions} for model '${model.name}'`, + }; + } + return { + provider: providerName, + model: model.name, + dimensions: requestedDimensions, + }; +} + +function schemaIdForName(name: string): string | undefined { + return getState().schemas.find(schema => schema.name === name)?._id; +} + +function provisionPendingIndex(config: MockEmbeddingConfig): void { + const state = getState(); + const schemaId = schemaIdForName(config.schemaName) ?? PRODUCT_SCHEMA_ID; + const indexes = state.indexesBySchemaId[schemaId] ?? []; + const existing = matchingIndex(config, schemaId); + if (existing) return; + indexes.push({ + name: `${config.schemaName}_${config.targetField}_v1`, + field: config.targetField, + dimensions: config.dimensions, + similarity: config.similarity, + method: 'hnsw', + status: 'pending', + queryable: false, + }); + state.indexesBySchemaId[schemaId] = indexes; +} + +function isIndexQueryable(config: MockEmbeddingConfig): boolean { + const index = matchingIndex(config, schemaIdForName(config.schemaName)); + if (!index) return false; + if (index.queryable === false) return false; + if (index.status === 'failed') return false; + if (index.status === 'pending' && index.queryable !== true) return false; + return index.queryable === true || index.status === 'ready'; +} + +function advanceBackfill(run: MockBackfillRun): MockBackfillRun { + if (!run.autoAdvance) return cloneRun(run); + const snapshot = cloneRun(run); + const elapsed = Date.now() - run.createdMs; + if (run.state === 'queued' && elapsed < 2_000) return snapshot; + if (run.state === 'running' && elapsed < 5_000) return snapshot; + run.pollCount += 1; + if (run.state === 'queued') { + run.state = 'running'; + run.startedAt = FIXED_NOW; + run.scannedCount = 40; + run.queuedCount = 40; + run.processedCount = 10; + run.updatedAt = FIXED_NOW; + run.cursor = 'cursor-running'; + } else if (run.state === 'running') { + run.state = 'completed'; + run.processedCount = run.queuedCount; + run.finishedAt = FIXED_NOW; + run.updatedAt = FIXED_NOW; + run.cursor = 'cursor-done'; + run.autoAdvance = false; + } + return snapshot; +} + +function adminRouterConfig() { + return { + hostUrl: 'http://127.0.0.1:3000', + transports: { rest: true, graphql: false, sockets: false }, + captcha: { enabled: false, provider: 'recaptcha', secretKey: '' }, + cors: { + enabled: false, + origin: '*', + methods: 'GET,POST,PUT,DELETE,PATCH', + allowedHeaders: '', + exposedHeaders: '', + credentials: true, + maxAge: 86400, + }, + rateLimit: { maxRequests: 100, resetInterval: 1 }, + security: { clientValidation: false }, + }; +} + +function adminModuleConfig() { + return { + hostUrl: 'http://127.0.0.1:3030', + transports: { rest: true, graphql: false, sockets: false, mcp: false }, + auth: { + tokenSecret: REDACTED_SECRET, + hashRounds: 11, + tokenExpirationTime: 72000, + }, + cors: { + enabled: false, + origin: '*', + methods: 'GET,POST,PUT,DELETE,PATCH', + allowedHeaders: '', + exposedHeaders: '', + credentials: true, + maxAge: 86400, + }, + mcp: { pingInterval: 30000, sessionTimeout: 300000 }, + }; +} + +async function handleLogin( + request: IncomingMessage, + response: ServerResponse +): Promise { + const body = await readJsonBody(request); + if (!isRecord(body)) { + badRequest(response, 'Invalid login payload'); + return; + } + if (body.username !== E2E_USERNAME || body.password !== E2E_PASSWORD) { + unauthorized(response); + return; + } + const token = signAdminToken(E2E_USERNAME); + registerIssuedToken(token); + sendJson(response, 200, { token }); +} + +function handleTestControl( + method: string, + pathname: string, + request: IncomingMessage, + response: ServerResponse +): Promise | boolean { + if (!pathname.startsWith('/__test__/')) return false; + if (!hasTestControlHeader(request)) { + sendJson(response, 403, { status: 403, message: 'Forbidden' }); + return true; + } + if (pathname === '/__test__/health' && method === 'GET') { + sendJson(response, 200, { ok: true }); + return true; + } + if (pathname === '/__test__/reset' && method === 'POST') { + return readJsonBody(request).then(body => { + const scenario = + isRecord(body) && isMockScenario(body.scenario) + ? body.scenario + : 'ready'; + resetState(scenario); + sendJson(response, 200, { ok: true, scenario }); + return true; + }); + } + if (pathname === '/__test__/revoke' && method === 'POST') { + revokeActiveTokens(); + sendJson(response, 200, { ok: true }); + return true; + } + if (pathname === '/__test__/state' && method === 'GET') { + const state = getState(); + const provider = + state.settings.providers[state.settings.defaultProvider] ?? + state.settings.providers[OPENAI_COMPATIBLE_PROVIDER]; + sendJson(response, 200, { + scenario: state.scenario, + configCount: state.configs.length, + backfillCount: state.backfills.length, + storedApiKeyConfigured: Boolean(provider?.apiKey), + lastSettingsPatchHadApiKey: state.lastSettingsPatchHadApiKey, + workersEnabled: state.settings.enabled, + modules: state.modules.map(module => module.moduleName), + }); + return true; + } + sendEmpty(response, 404); + return true; +} + +export async function handleMockRequest( + request: IncomingMessage, + response: ServerResponse +): Promise { + const method = (request.method ?? 'GET').toUpperCase(); + const { pathname, search } = requestPath(request); + + if (pathname === '/ready' && method === 'GET') { + sendJson(response, 200, { ready: true }); + return; + } + + const testHandled = await handleTestControl( + method, + pathname, + request, + response + ); + if (testHandled) return; + + if (!requireAuth(request, response, pathname, method)) return; + + if (pathname === '/login' && method === 'POST') { + await handleLogin(request, response); + return; + } + + if (pathname === '/admins/me' && method === 'GET') { + sendJson(response, 200, { + _id: 'admin_1', + username: E2E_USERNAME, + email: 'admin@example.com', + isSuperAdmin: true, + hasTwoFA: false, + createdAt: FIXED_NOW, + updatedAt: FIXED_NOW, + }); + return; + } + + if (pathname === '/config/modules' && method === 'GET') { + sendJson(response, 200, { modules: getState().modules }); + return; + } + + if (pathname === '/config/router' && method === 'GET') { + sendJson(response, 200, { config: adminRouterConfig() }); + return; + } + + if (pathname === '/config/admin' && method === 'GET') { + sendJson(response, 200, { config: adminModuleConfig() }); + return; + } + + if (pathname === '/config/embeddings' && method === 'GET') { + sendJson(response, 200, { config: toApiSettings(getState().settings) }); + return; + } + + if (pathname === '/config/embeddings' && method === 'PATCH') { + const body = await readJsonBody(request); + const patch = isRecord(body) ? body.config : undefined; + try { + const applied = applySettingsPatch(getState().settings, patch); + getState().settings = applied.settings; + getState().lastSettingsPatchHadApiKey = applied.hadApiKey; + sendJson(response, 200, { config: toApiSettings(applied.settings) }); + } catch (err) { + const message = + err instanceof Error ? err.message : 'Invalid embeddings settings'; + badRequest(response, message); + } + return; + } + + if (pathname === '/embeddings/capabilities' && method === 'GET') { + sendJson(response, 200, { + capabilities: getState().capabilities, + warnings: [], + }); + return; + } + + if (pathname === '/embeddings/status' && method === 'GET') { + const state = getState(); + const provider = + state.settings.providers[state.settings.defaultProvider] ?? + state.settings.providers[OPENAI_COMPATIBLE_PROVIDER]; + const ready = + state.settings.enabled && + state.capabilities.supported && + state.capabilities.storage && + state.capabilities.search && + Boolean(provider?.endpoint) && + Boolean(provider?.apiKey) && + (provider?.models?.length ?? 0) > 0; + sendJson(response, 200, { + enabled: state.settings.enabled, + ready, + capabilities: state.capabilities, + generationQueue: emptyQueueCounts(), + backfillQueue: emptyQueueCounts(), + warnings: [], + }); + return; + } + + if (pathname === '/embeddings/configs' && method === 'GET') { + const schemaName = search.get('schemaName') ?? undefined; + const id = search.get('id') ?? undefined; + let configs = getState().configs; + if (schemaName) { + configs = configs.filter(config => config.schemaName === schemaName); + } + if (id) configs = configs.filter(config => config._id === id); + sendJson(response, 200, { configs: configs.map(toApiConfig) }); + return; + } + + if (pathname === '/embeddings/configs' && method === 'POST') { + const body = await readJsonBody(request); + if (!isRecord(body)) { + badRequest(response, 'Invalid config payload'); + return; + } + const schemaName = readString(body.schemaName); + const targetField = readString(body.targetField); + const sourceFields = readStringArray(body.sourceFields); + if (!schemaName || !targetField || sourceFields.length === 0) { + badRequest(response, 'Invalid config payload'); + return; + } + const state = getState(); + const schema = state.schemas.find(item => item.name === schemaName); + if (!schema) { + badRequest(response, `Schema '${schemaName}' is not enabled`); + return; + } + const eligibilityError = canReceiveEmbeddings(schema); + if (eligibilityError) { + sendJson(response, 412, { status: 412, message: eligibilityError }); + return; + } + const resolved = resolveCatalogueModel( + state.settings, + readString(body.provider), + readString(body.model), + readNumber(body.dimensions) + ); + if ('error' in resolved) { + badRequest(response, resolved.error); + return; + } + const similarity = + body.similarity === 'euclidean' || body.similarity === 'dotProduct' + ? body.similarity + : 'cosine'; + const collision = embeddingExtensionCollision( + schema, + targetField, + resolved.dimensions, + similarity + ); + if (collision) { + sendJson(response, 409, { status: 409, message: collision }); + return; + } + const existing = state.configs.find( + config => + config.schemaName === schemaName && config.targetField === targetField + ); + const next: MockEmbeddingConfig = { + _id: existing?._id ?? `cfg_${++state.configSeq}`, + schemaName, + sourceFields, + targetField, + provider: resolved.provider, + model: resolved.model, + dimensions: resolved.dimensions, + similarity, + enabled: false, + createdAt: existing?.createdAt ?? FIXED_NOW, + updatedAt: FIXED_NOW, + }; + provisionPendingIndex(next); + const warnings: string[] = []; + const requestedEnabled = body.enabled === true; + if (requestedEnabled && !isIndexQueryable(next)) { + warnings.push( + 'Config stayed disabled until the matching index is queryable.' + ); + } else if (!isIndexQueryable(next)) { + warnings.push( + 'Matching index is pending. Keep this config disabled until it is queryable.' + ); + } else { + next.enabled = requestedEnabled; + } + if (existing) { + state.configs = state.configs.map(config => + config._id === existing._id ? next : config + ); + } else { + state.configs.push(next); + } + sendJson(response, 200, { config: toApiConfig(next), warnings }); + return; + } + + const configMatch = /^\/embeddings\/configs\/([^/]+)$/.exec(pathname); + if (configMatch && method === 'GET') { + const config = getState().configs.find(item => item._id === configMatch[1]); + if (!config) { + notFound(response, 'Embedding config not found'); + return; + } + sendJson(response, 200, { config: toApiConfig(config) }); + return; + } + if (configMatch && method === 'DELETE') { + const state = getState(); + const config = state.configs.find(item => item._id === configMatch[1]); + if (!config) { + notFound(response, 'Embedding config not found'); + return; + } + state.configs = state.configs.filter(item => item._id !== config._id); + sendJson(response, 200, { config: toApiConfig(config) }); + return; + } + + if (pathname === '/embeddings/backfills' && method === 'GET') { + const schemaName = search.get('schemaName') ?? undefined; + const configId = search.get('configId') ?? undefined; + const runState = search.get('state') ?? undefined; + const skip = Number(search.get('skip') ?? '0') || 0; + const limit = Number(search.get('limit') ?? '10') || 10; + let runs = getState().backfills.map(toApiRun); + if (schemaName) runs = runs.filter(run => run.schemaName === schemaName); + if (configId) runs = runs.filter(run => run.configId === configId); + if (runState) runs = runs.filter(run => run.state === runState); + const count = runs.length; + sendJson(response, 200, { runs: runs.slice(skip, skip + limit), count }); + return; + } + + if (pathname === '/embeddings/backfills' && method === 'POST') { + const body = await readJsonBody(request); + if (!isRecord(body)) { + badRequest(response, 'Invalid backfill payload'); + return; + } + const schemaName = readString(body.schemaName); + if (!schemaName) { + badRequest(response, 'schemaName is required'); + return; + } + const state = getState(); + const run: MockBackfillRun = { + _id: `bf_${++state.backfillSeq}`, + schemaName, + configId: readString(body.configId), + state: 'queued', + batchSize: readNumber(body.batchSize, 100) ?? 100, + onlyMissing: readBoolean(body.onlyMissing, true), + filter: readFilter(body.filter), + scannedCount: 0, + queuedCount: 0, + processedCount: 0, + failedCount: 0, + createdAt: FIXED_NOW, + updatedAt: FIXED_NOW, + autoAdvance: true, + pollCount: 0, + createdMs: Date.now(), + }; + state.backfills.unshift(run); + sendJson(response, 200, { + queued: 1, + runs: [toApiRun(run)], + warnings: [], + }); + return; + } + + const backfillCancel = /^\/embeddings\/backfills\/([^/]+)\/cancel$/.exec( + pathname + ); + if (backfillCancel && method === 'POST') { + const run = getState().backfills.find( + item => item._id === backfillCancel[1] + ); + if (!run) { + notFound(response, 'Backfill run not found'); + return; + } + if (run.state !== 'queued' && run.state !== 'running') { + badRequest(response, 'Backfill cannot be canceled'); + return; + } + run.state = 'canceled'; + run.autoAdvance = false; + run.finishedAt = FIXED_NOW; + run.updatedAt = FIXED_NOW; + sendJson(response, 200, { run: toApiRun(run) }); + return; + } + + const backfillResume = /^\/embeddings\/backfills\/([^/]+)\/resume$/.exec( + pathname + ); + if (backfillResume && method === 'POST') { + const run = getState().backfills.find( + item => item._id === backfillResume[1] + ); + if (!run) { + notFound(response, 'Backfill run not found'); + return; + } + if (run.state !== 'failed' && run.state !== 'canceled') { + badRequest(response, 'Backfill cannot be resumed'); + return; + } + run.state = 'queued'; + run.autoAdvance = true; + run.pollCount = 0; + run.createdMs = Date.now(); + run.finishedAt = undefined; + run.error = undefined; + run.updatedAt = FIXED_NOW; + sendJson(response, 200, { run: toApiRun(run) }); + return; + } + + const backfillGet = /^\/embeddings\/backfills\/([^/]+)$/.exec(pathname); + if (backfillGet && method === 'GET') { + const run = getState().backfills.find(item => item._id === backfillGet[1]); + if (!run) { + notFound(response, 'Backfill run not found'); + return; + } + sendJson(response, 200, toApiRun(advanceBackfill(run))); + return; + } + + if (pathname === '/embeddings/search' && method === 'POST') { + const body = await readJsonBody(request); + if (!isRecord(body)) { + badRequest(response, 'Invalid search payload'); + return; + } + const text = readString(body.text); + const schemaName = readString(body.schemaName); + if (!text || !schemaName) { + badRequest(response, 'schemaName and text are required'); + return; + } + if (text === 'fail') { + sendJson(response, 503, { + status: 503, + message: 'Provider request failed', + }); + return; + } + if (text === 'nomatch') { + sendJson(response, 200, { hits: [] }); + return; + } + sendJson(response, 200, { + hits: [ + { + document: { + _id: 'doc_1', + title: 'Published guide', + schemaName, + password: 'super-secret', + apiKey: 'sk-live-secret', + embedding: [0, 1, 2, 3, 4, 5, 6, 7, 8], + }, + score: 0.91, + distance: 0.09, + metric: 'cosine', + provider: 'mongodb', + }, + { + document: { + _id: 'doc_2', + title: 'Operator notes', + schemaName, + refreshToken: 'tok-secret', + embedding: [8, 7, 6, 5, 4, 3, 2, 1, 0], + }, + score: 0.74, + distance: 0.26, + metric: 'cosine', + provider: 'mongodb', + }, + ], + }); + return; + } + + if (pathname === '/database/schemas/system' && method === 'GET') { + sendJson(response, 200, { + databaseSystemSchemas: [...DATABASE_SYSTEM_SCHEMAS], + }); + return; + } + + if (pathname === '/database/schemas' && method === 'GET') { + const enabledParam = search.get('enabled'); + let schemas = getState().schemas; + if (enabledParam === 'true') { + schemas = schemas.filter(isMockSchemaEnabled); + } else if (enabledParam === 'false') { + schemas = schemas.filter(schema => !isMockSchemaEnabled(schema)); + } + sendJson(response, 200, { + schemas, + count: schemas.length, + }); + return; + } + + const vectorIndexes = /^\/database\/schemas\/([^/]+)\/vector-indexes$/.exec( + pathname + ); + if (vectorIndexes && method === 'GET') { + sendJson(response, 200, { + indexes: getState().indexesBySchemaId[vectorIndexes[1]] ?? [], + }); + return; + } + + if (pathname === '/database/database-type' && method === 'GET') { + sendJson(response, 200, { result: 'MongoDB' }); + return; + } + + sendEmpty(response, 404); +} diff --git a/e2e/mock-admin/http.ts b/e2e/mock-admin/http.ts new file mode 100644 index 000000000..777da3a72 --- /dev/null +++ b/e2e/mock-admin/http.ts @@ -0,0 +1,82 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; + +export type JsonRecord = Record; + +export function isRecord(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export async function readJsonBody(request: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); + } + if (chunks.length === 0) return undefined; + const raw = Buffer.concat(chunks).toString('utf8').trim(); + if (!raw) return undefined; + return JSON.parse(raw) as unknown; +} + +export function sendJson( + response: ServerResponse, + status: number, + body: unknown +): void { + const payload = JSON.stringify(body); + response.writeHead(status, { + 'content-type': 'application/json; charset=utf-8', + 'content-length': Buffer.byteLength(payload), + }); + response.end(payload); +} + +export function sendEmpty(response: ServerResponse, status: number): void { + response.writeHead(status); + response.end(); +} + +export function requestPath(request: IncomingMessage): { + pathname: string; + search: URLSearchParams; +} { + const url = new URL(request.url ?? '/', 'http://127.0.0.1'); + return { pathname: url.pathname, search: url.searchParams }; +} + +export function bearerToken(request: IncomingMessage): string | undefined { + const header = request.headers.authorization; + if (typeof header !== 'string') return undefined; + const match = /^Bearer\s+(.+)$/i.exec(header.trim()); + return match?.[1]; +} + +export function masterKey(request: IncomingMessage): string | undefined { + const value = request.headers.masterkey; + if (Array.isArray(value)) return value[0]; + return value; +} + +export function readString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +export function readNumber( + value: unknown, + fallback?: number +): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return fallback; +} + +export function readBoolean(value: unknown, fallback = false): boolean { + return typeof value === 'boolean' ? value : fallback; +} + +export function readStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === 'string'); +} diff --git a/e2e/mock-admin/jwt.ts b/e2e/mock-admin/jwt.ts new file mode 100644 index 000000000..ba8d0fe8e --- /dev/null +++ b/e2e/mock-admin/jwt.ts @@ -0,0 +1,20 @@ +import { createHmac } from 'node:crypto'; +import { JWT_SECRET } from './constants.ts'; + +export function signAdminToken(username: string): string { + const header = Buffer.from( + JSON.stringify({ alg: 'HS256', typ: 'JWT' }) + ).toString('base64url'); + const payload = Buffer.from( + JSON.stringify({ + username, + twoFaRequired: false, + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 72_000, + }) + ).toString('base64url'); + const signature = createHmac('sha256', JWT_SECRET) + .update(`${header}.${payload}`) + .digest('base64url'); + return `${header}.${payload}.${signature}`; +} diff --git a/e2e/mock-admin/server.ts b/e2e/mock-admin/server.ts new file mode 100644 index 000000000..8ded31153 --- /dev/null +++ b/e2e/mock-admin/server.ts @@ -0,0 +1,21 @@ +import { createServer, type Server } from 'node:http'; +import { handleMockRequest } from './handlers.ts'; + +export function startMockAdminServer( + host: string, + port: number +): Promise { + const server = createServer((request, response) => { + void handleMockRequest(request, response).catch(error => { + const message = error instanceof Error ? error.message : 'Mock error'; + if (!response.headersSent) { + response.writeHead(500, { 'content-type': 'application/json' }); + } + response.end(JSON.stringify({ status: 500, message })); + }); + }); + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, host, () => resolve(server)); + }); +} diff --git a/e2e/mock-admin/start.mts b/e2e/mock-admin/start.mts new file mode 100644 index 000000000..cefac5dc5 --- /dev/null +++ b/e2e/mock-admin/start.mts @@ -0,0 +1,5 @@ +import { MOCK_ADMIN_HOST, MOCK_ADMIN_PORT } from './constants.ts'; +import { startMockAdminServer } from './server.ts'; + +const port = Number(process.env.MOCK_ADMIN_PORT ?? MOCK_ADMIN_PORT); +await startMockAdminServer(MOCK_ADMIN_HOST, port); diff --git a/e2e/mock-admin/state.ts b/e2e/mock-admin/state.ts new file mode 100644 index 000000000..60fdbde41 --- /dev/null +++ b/e2e/mock-admin/state.ts @@ -0,0 +1,512 @@ +import { + ADMIN_MIDDLEWARE_SCHEMA_ID, + ADMIN_MIDDLEWARE_SCHEMA_NAME, + ADMIN_SCHEMA_ID, + ADMIN_SCHEMA_NAME, + APP_MIDDLEWARE_SCHEMA_ID, + APP_MIDDLEWARE_SCHEMA_NAME, + ARCHIVED_SCHEMA_ID, + ARCHIVED_SCHEMA_NAME, + CLIENT_SCHEMA_ID, + CLIENT_SCHEMA_NAME, + CMS_ONLY_SCHEMA_ID, + CMS_ONLY_SCHEMA_NAME, + COLLISION_SCHEMA_ID, + COLLISION_SCHEMA_NAME, + CONFIG_SCHEMA_ID, + CONFIG_SCHEMA_NAME, + E2E_ENV_NAME, + FIXED_NOW, + LEGACY_CONFIG_ID, + LEGACY_MODEL, + OPENAI_COMPATIBLE_PROVIDER, + PENDING_INDEX_NAME, + PRODUCT_SCHEMA_ID, + PRODUCT_SCHEMA_NAME, + PROVIDER_ENDPOINT, + PROVIDER_MODEL, + PROVIDER_DIMENSIONS, + READY_CONFIG_ID, + READY_INDEX_NAME, + SECOND_PROVIDER, + SECOND_PROVIDER_DIMENSIONS, + SECOND_PROVIDER_MODEL, + STORED_API_KEY, + TEAM_SCHEMA_ID, + TEAM_SCHEMA_NAME, + USER_SCHEMA_ID, + USER_SCHEMA_NAME, + VIEWS_SCHEMA_ID, + VIEWS_SCHEMA_NAME, +} from './constants.ts'; +import type { + MockAdminState, + MockBackfillRun, + MockCapabilities, + MockEmbeddingConfig, + MockEmbeddingsSettings, + MockModule, + MockQueueCounts, + MockScenario, + MockSchema, +} from './types.ts'; + +function emptyQueue(): MockQueueCounts { + return { + waiting: 0, + active: 0, + completed: 0, + failed: 0, + delayed: 0, + paused: 0, + }; +} + +function schemaModelOptions(args: { + enabled: boolean; + extendable: boolean; +}): Record { + return { + conduit: { + cms: { enabled: args.enabled }, + permissions: { extendable: args.extendable }, + }, + }; +} + +function namedSchema(args: { + id: string; + name: string; + ownerModule: string; + collectionName: string; + enabled?: boolean; + extendable?: boolean; + fields?: Record; +}): MockSchema { + const fields = args.fields ?? { + title: { type: 'String' }, + }; + return { + _id: args.id, + name: args.name, + parentSchema: null, + fields, + compiledFields: fields, + extensions: [], + modelOptions: schemaModelOptions({ + enabled: args.enabled ?? true, + extendable: args.extendable ?? true, + }), + ownerModule: args.ownerModule, + collectionName: args.collectionName, + createdAt: FIXED_NOW, + updatedAt: FIXED_NOW, + }; +} + +function declaredSchemas(): MockSchema[] { + return [ + namedSchema({ + id: PRODUCT_SCHEMA_ID, + name: PRODUCT_SCHEMA_NAME, + ownerModule: 'database', + collectionName: 'products', + fields: { + title: { type: 'String' }, + description: { type: 'String' }, + }, + }), + namedSchema({ + id: ARCHIVED_SCHEMA_ID, + name: ARCHIVED_SCHEMA_NAME, + ownerModule: 'database', + collectionName: 'archived_products', + enabled: false, + }), + namedSchema({ + id: CMS_ONLY_SCHEMA_ID, + name: CMS_ONLY_SCHEMA_NAME, + ownerModule: 'database', + collectionName: 'cms_only', + extendable: false, + }), + namedSchema({ + id: COLLISION_SCHEMA_ID, + name: COLLISION_SCHEMA_NAME, + ownerModule: 'database', + collectionName: 'notes', + fields: { + title: { type: 'String' }, + embedding: { type: 'String' }, + }, + }), + namedSchema({ + id: USER_SCHEMA_ID, + name: USER_SCHEMA_NAME, + ownerModule: 'authentication', + collectionName: 'users', + fields: { email: { type: 'String' } }, + }), + namedSchema({ + id: TEAM_SCHEMA_ID, + name: TEAM_SCHEMA_NAME, + ownerModule: 'authentication', + collectionName: 'teams', + fields: { name: { type: 'String' } }, + }), + namedSchema({ + id: ADMIN_SCHEMA_ID, + name: ADMIN_SCHEMA_NAME, + ownerModule: 'core', + collectionName: 'admins', + fields: { username: { type: 'String' } }, + }), + namedSchema({ + id: ADMIN_MIDDLEWARE_SCHEMA_ID, + name: ADMIN_MIDDLEWARE_SCHEMA_NAME, + ownerModule: 'core', + collectionName: 'admin_middleware', + fields: { path: { type: 'String' } }, + }), + namedSchema({ + id: APP_MIDDLEWARE_SCHEMA_ID, + name: APP_MIDDLEWARE_SCHEMA_NAME, + ownerModule: 'router', + collectionName: 'app_middleware', + fields: { path: { type: 'String' } }, + }), + namedSchema({ + id: CLIENT_SCHEMA_ID, + name: CLIENT_SCHEMA_NAME, + ownerModule: 'router', + collectionName: 'clients', + fields: { alias: { type: 'String' } }, + }), + namedSchema({ + id: CONFIG_SCHEMA_ID, + name: CONFIG_SCHEMA_NAME, + ownerModule: 'core', + collectionName: 'config', + fields: { name: { type: 'String' } }, + }), + namedSchema({ + id: VIEWS_SCHEMA_ID, + name: VIEWS_SCHEMA_NAME, + ownerModule: 'database', + collectionName: 'views', + fields: { name: { type: 'String' } }, + }), + ]; +} + +function coreModules( + includeEmbeddings: boolean, + serving: boolean +): MockModule[] { + const modules: MockModule[] = [ + { moduleName: 'database', url: '0.0.0.0:5510', serving: true }, + { moduleName: 'router', url: '0.0.0.0:5511', serving: true }, + ]; + if (includeEmbeddings) { + modules.push({ + moduleName: 'embeddings', + url: '0.0.0.0:5512', + serving, + }); + } + return modules; +} + +function defaultSettings(args: { + enabled: boolean; + apiKey: string; +}): MockEmbeddingsSettings { + return { + enabled: args.enabled, + defaultProvider: OPENAI_COMPATIBLE_PROVIDER, + providers: { + [OPENAI_COMPATIBLE_PROVIDER]: { + endpoint: PROVIDER_ENDPOINT, + apiKey: args.apiKey, + models: [{ name: PROVIDER_MODEL, dimensions: PROVIDER_DIMENSIONS }], + defaultModel: PROVIDER_MODEL, + }, + }, + queue: { + concurrency: 2, + attempts: 3, + maxBatchSize: 500, + drainTimeoutMs: 15 * 60 * 1000, + }, + security: { + sourceFieldAllowlist: [], + maxMutationEventIds: 500, + embedTimeoutMs: 10_000, + maxEmbedInputBytes: 32 * 1024, + maxEmbedResponseBytes: 1024 * 1024, + }, + }; +} + +function mongodbCapabilities(): MockCapabilities { + return { + supported: true, + storage: true, + indexing: true, + search: true, + provider: 'mongodb', + }; +} + +function unsupportedCapabilities(): MockCapabilities { + return { + supported: false, + storage: false, + indexing: false, + search: false, + provider: 'unsupported', + reason: 'This database does not support vector storage and search', + }; +} + +function legacyConfig(): MockEmbeddingConfig { + return { + _id: LEGACY_CONFIG_ID, + schemaName: PRODUCT_SCHEMA_NAME, + sourceFields: ['title'], + targetField: 'legacyEmbedding', + provider: OPENAI_COMPATIBLE_PROVIDER, + model: LEGACY_MODEL, + dimensions: 1536, + similarity: 'cosine', + enabled: false, + createdAt: FIXED_NOW, + updatedAt: FIXED_NOW, + }; +} + +function readyConfig(): MockEmbeddingConfig { + return { + _id: READY_CONFIG_ID, + schemaName: PRODUCT_SCHEMA_NAME, + sourceFields: ['title'], + targetField: 'embedding', + provider: OPENAI_COMPATIBLE_PROVIDER, + model: PROVIDER_MODEL, + dimensions: 1536, + similarity: 'cosine', + enabled: true, + createdAt: FIXED_NOW, + updatedAt: FIXED_NOW, + }; +} + +export function emptyQueueCounts(): MockQueueCounts { + return emptyQueue(); +} + +function readyIndexes() { + return [ + { + name: PENDING_INDEX_NAME, + field: 'embedding', + dimensions: 1536, + similarity: 'cosine' as const, + method: 'hnsw' as const, + status: 'pending' as const, + queryable: false, + }, + { + name: READY_INDEX_NAME, + field: 'embedding', + dimensions: 1536, + similarity: 'cosine' as const, + method: 'hnsw' as const, + status: 'ready' as const, + queryable: true, + }, + ]; +} + +function readyState(enabled: boolean): MockAdminState { + return { + scenario: enabled ? 'ready' : 'workers-off', + modules: coreModules(true, true), + settings: defaultSettings({ enabled, apiKey: STORED_API_KEY }), + capabilities: mongodbCapabilities(), + schemas: declaredSchemas(), + configs: [readyConfig(), legacyConfig()], + indexesBySchemaId: { + [PRODUCT_SCHEMA_ID]: readyIndexes(), + }, + backfills: [], + tokens: new Set(), + lastSettingsPatchHadApiKey: false, + configSeq: 1, + backfillSeq: 0, + }; +} + +export function createState(scenario: MockScenario = 'ready'): MockAdminState { + switch (scenario) { + case 'ready': + return readyState(true); + case 'workers-off': + return readyState(false); + case 'gated': + return { + scenario, + modules: coreModules(true, true), + settings: defaultSettings({ enabled: false, apiKey: '' }), + capabilities: unsupportedCapabilities(), + schemas: declaredSchemas(), + configs: [], + indexesBySchemaId: { [PRODUCT_SCHEMA_ID]: [] }, + backfills: [], + tokens: new Set(), + lastSettingsPatchHadApiKey: false, + configSeq: 0, + backfillSeq: 0, + }; + case 'blank': + return { + scenario, + modules: coreModules(true, true), + settings: (() => { + const settings = defaultSettings({ + enabled: true, + apiKey: STORED_API_KEY, + }); + settings.providers[OPENAI_COMPATIBLE_PROVIDER] = { + ...settings.providers[OPENAI_COMPATIBLE_PROVIDER], + models: [ + { + name: PROVIDER_MODEL, + dimensions: PROVIDER_DIMENSIONS, + }, + { + name: 'text-embedding-3-large', + dimensions: 3072, + }, + ], + defaultModel: PROVIDER_MODEL, + }; + settings.providers[SECOND_PROVIDER] = { + endpoint: PROVIDER_ENDPOINT, + apiKey: STORED_API_KEY, + models: [ + { + name: SECOND_PROVIDER_MODEL, + dimensions: SECOND_PROVIDER_DIMENSIONS, + }, + ], + defaultModel: SECOND_PROVIDER_MODEL, + }; + return settings; + })(), + capabilities: mongodbCapabilities(), + schemas: declaredSchemas(), + configs: [], + indexesBySchemaId: { [PRODUCT_SCHEMA_ID]: [] }, + backfills: [], + tokens: new Set(), + lastSettingsPatchHadApiKey: false, + configSeq: 0, + backfillSeq: 0, + }; + case 'no-embeddings': + return { + scenario, + modules: coreModules(false, false), + settings: defaultSettings({ enabled: false, apiKey: '' }), + capabilities: unsupportedCapabilities(), + schemas: declaredSchemas(), + configs: [], + indexesBySchemaId: { [PRODUCT_SCHEMA_ID]: [] }, + backfills: [], + tokens: new Set(), + lastSettingsPatchHadApiKey: false, + configSeq: 0, + backfillSeq: 0, + }; + default: { + const exhaustive: never = scenario; + return exhaustive; + } + } +} + +let state: MockAdminState = createState('ready'); +const issuedTokens = new Set(); + +export function getState(): MockAdminState { + return state; +} + +export function registerIssuedToken(token: string): void { + issuedTokens.add(token); + state.tokens.add(token); +} + +export function revokeActiveTokens(): void { + state.tokens.clear(); +} + +export function resetState(scenario: MockScenario = 'ready'): MockAdminState { + state = createState(scenario); + state.tokens = new Set(issuedTokens); + return state; +} + +export function replaceState(next: MockAdminState): void { + state = next; +} + +export function envDisplayName(): string { + return E2E_ENV_NAME; +} + +export function cloneRun(run: MockBackfillRun): MockBackfillRun { + return { + ...run, + filter: run.filter ? { ...run.filter } : undefined, + }; +} + +export function toApiConfig(config: MockEmbeddingConfig) { + return { + id: config._id, + schemaName: config.schemaName, + sourceFields: config.sourceFields, + targetField: config.targetField, + provider: config.provider, + model: config.model, + dimensions: config.dimensions, + similarity: config.similarity, + enabled: config.enabled, + createdAt: config.createdAt, + updatedAt: config.updatedAt, + }; +} + +export function toApiRun(run: MockBackfillRun) { + return { + id: run._id, + schemaName: run.schemaName, + configId: run.configId, + state: run.state, + cursor: run.cursor, + batchSize: run.batchSize, + onlyMissing: run.onlyMissing, + filter: run.filter ? JSON.stringify(run.filter) : undefined, + scannedCount: run.scannedCount, + queuedCount: run.queuedCount, + processedCount: run.processedCount, + failedCount: run.failedCount, + startedAt: run.startedAt, + finishedAt: run.finishedAt, + error: run.error, + createdAt: run.createdAt, + updatedAt: run.updatedAt, + }; +} diff --git a/e2e/mock-admin/types.ts b/e2e/mock-admin/types.ts new file mode 100644 index 000000000..3f4ca7e22 --- /dev/null +++ b/e2e/mock-admin/types.ts @@ -0,0 +1,148 @@ +export const MOCK_SCENARIOS = [ + 'ready', + 'workers-off', + 'gated', + 'blank', + 'no-embeddings', +] as const; + +export type MockScenario = (typeof MOCK_SCENARIOS)[number]; + +export function isMockScenario(value: unknown): value is MockScenario { + return ( + typeof value === 'string' && + (MOCK_SCENARIOS as readonly string[]).includes(value) + ); +} + +export type MockModule = { + moduleName: string; + url: string; + serving: boolean; +}; + +export type MockProviderModel = { + name: string; + dimensions: number; +}; + +export type MockProviderSettings = { + endpoint: string; + apiKey: string; + models: MockProviderModel[]; + defaultModel: string; +}; + +export type MockEmbeddingsSettings = { + enabled: boolean; + defaultProvider: string; + providers: Record; + queue: { + concurrency: number; + attempts: number; + maxBatchSize: number; + drainTimeoutMs: number; + }; + security: { + sourceFieldAllowlist: string[]; + maxMutationEventIds: number; + embedTimeoutMs: number; + maxEmbedInputBytes: number; + maxEmbedResponseBytes: number; + }; +}; + +export type MockCapabilities = { + supported: boolean; + storage: boolean; + indexing: boolean; + search: boolean; + provider: 'mongodb' | 'postgres' | 'unsupported'; + reason?: string; +}; + +export type MockQueueCounts = { + waiting: number; + active: number; + completed: number; + failed: number; + delayed: number; + paused: number; +}; + +export type MockEmbeddingConfig = { + _id: string; + schemaName: string; + sourceFields: string[]; + targetField: string; + provider: string; + model: string; + dimensions: number; + similarity: 'cosine' | 'euclidean' | 'dotProduct'; + enabled: boolean; + createdAt: string; + updatedAt: string; +}; + +export type MockVectorIndex = { + name: string; + field: string; + dimensions: number; + similarity: 'cosine' | 'euclidean' | 'dotProduct'; + method: 'hnsw' | 'ivfflat' | 'flat'; + status: 'pending' | 'ready' | 'failed'; + queryable: boolean; +}; + +export type MockSchema = { + _id: string; + name: string; + parentSchema: string | null; + fields: Record; + compiledFields: Record; + extensions: unknown[]; + modelOptions: Record; + ownerModule: string; + collectionName: string; + createdAt: string; + updatedAt: string; +}; + +export type MockBackfillRun = { + _id: string; + schemaName: string; + configId?: string; + state: 'queued' | 'running' | 'completed' | 'failed' | 'canceled'; + cursor?: string; + batchSize: number; + onlyMissing: boolean; + filter?: Record; + scannedCount: number; + queuedCount: number; + processedCount: number; + failedCount: number; + startedAt?: string; + finishedAt?: string; + drainStartedAt?: string; + error?: string; + createdAt: string; + updatedAt: string; + autoAdvance: boolean; + pollCount: number; + createdMs: number; +}; + +export type MockAdminState = { + scenario: MockScenario; + modules: MockModule[]; + settings: MockEmbeddingsSettings; + capabilities: MockCapabilities; + schemas: MockSchema[]; + configs: MockEmbeddingConfig[]; + indexesBySchemaId: Record; + backfills: MockBackfillRun[]; + tokens: Set; + lastSettingsPatchHadApiKey: boolean; + configSeq: number; + backfillSeq: number; +}; diff --git a/e2e/navigation.spec.ts b/e2e/navigation.spec.ts new file mode 100644 index 000000000..0832627b3 --- /dev/null +++ b/e2e/navigation.spec.ts @@ -0,0 +1,28 @@ +import { expect, test } from '@playwright/test'; +import { expectNoEmbeddingsNav, openEmbeddingsNav } from './helpers/ui.ts'; +import { resetMock } from './helpers/mock.ts'; + +test.describe('embeddings navigation', () => { + test('shows Embeddings in the sidebar when the module is registered', async ({ + page, + }) => { + await resetMock('ready'); + await page.goto('/'); + await openEmbeddingsNav(page); + await page.locator('a[href="/embeddings"]').first().click(); + await expect(page).toHaveURL(/\/embeddings$/); + await expect( + page.getByRole('heading', { name: 'Readiness' }) + ).toBeVisible(); + }); + + test('hides Embeddings when the module is absent', async ({ page }) => { + await resetMock('no-embeddings'); + await page.goto('/'); + await expectNoEmbeddingsNav(page); + await page.goto('/embeddings'); + await expect( + page.getByRole('heading', { name: 'Embeddings Module Not Deployed' }) + ).toBeVisible(); + }); +}); diff --git a/e2e/overview.spec.ts b/e2e/overview.spec.ts new file mode 100644 index 000000000..cec4db39f --- /dev/null +++ b/e2e/overview.spec.ts @@ -0,0 +1,55 @@ +import { expect, test } from '@playwright/test'; +import { resetMock } from './helpers/mock.ts'; + +test.describe('embeddings overview', () => { + test('shows a ready operator path', async ({ page }) => { + await resetMock('ready'); + await page.goto('/embeddings'); + await expect( + page.getByRole('heading', { name: 'Readiness' }) + ).toBeVisible(); + await expect( + page.getByText( + 'Capabilities, provider, index, config, and workers are ready.' + ) + ).toBeVisible(); + await expect(page.getByText('Vector storage and search')).toBeVisible(); + await expect(page.getByText('Provider configured')).toBeVisible(); + await expect(page.getByText('Matching index queryable')).toBeVisible(); + await expect(page.getByText('Config enabled')).toBeVisible(); + await expect(page.getByText('Workers enabled')).toBeVisible(); + await expect(page.getByText('Ready').first()).toBeVisible(); + await expect(page.getByText('Module Information')).toHaveCount(0); + const readiness = page.getByRole('heading', { name: 'Readiness' }); + const metrics = page.getByRole('heading', { name: 'Key Metrics' }); + await expect(readiness).toBeVisible(); + const readinessBox = await readiness.boundingBox(); + const metricsBox = await metrics.boundingBox(); + expect(readinessBox && metricsBox && readinessBox.y < metricsBox.y).toBe( + true + ); + }); + + test('shows a gated overview when capabilities and workers are blocked', async ({ + page, + }) => { + await resetMock('gated'); + await page.goto('/embeddings'); + await expect( + page.getByRole('heading', { name: 'Readiness' }) + ).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'Vector capabilities unavailable' }) + ).toBeVisible(); + await expect( + page.getByRole('heading', { name: 'Workers disabled' }) + ).toBeVisible(); + await expect( + page.getByRole('link', { name: 'Open Database' }) + ).toBeVisible(); + await expect( + page.getByRole('link', { name: 'Configure provider' }).first() + ).toBeVisible(); + await expect(page.getByText('Module Information')).toHaveCount(0); + }); +}); diff --git a/e2e/search.spec.ts b/e2e/search.spec.ts new file mode 100644 index 000000000..20c15a0cc --- /dev/null +++ b/e2e/search.spec.ts @@ -0,0 +1,68 @@ +import { expect, test } from '@playwright/test'; +import { resetMock } from './helpers/mock.ts'; + +test.describe('test search', () => { + test('returns hits, an empty result, and a provider error', async ({ + page, + }) => { + await resetMock('ready'); + await page.goto('/embeddings/test'); + await expect( + page.getByRole('heading', { name: 'Test Search' }) + ).toBeVisible(); + const query = page.getByLabel('Query'); + await query.fill('published guide'); + await page.getByRole('button', { name: 'Search' }).click(); + await expect( + page.getByRole('cell', { name: 'Published guide' }) + ).toBeVisible(); + await expect( + page.getByText('2 results. Higher score is better.') + ).toBeVisible(); + await expect(page.getByText('super-secret')).toHaveCount(0); + await expect(page.getByText('sk-live-secret')).toHaveCount(0); + await expect(page.getByText('tok-secret')).toHaveCount(0); + await expect( + page.getByRole('columnheader', { name: 'password' }) + ).toHaveCount(0); + await expect( + page.getByRole('columnheader', { name: 'embedding' }) + ).toHaveCount(0); + await page.getByRole('tab', { name: 'JSON' }).click(); + await expect(page.getByRole('tree', { name: 'JSON view' })).toBeVisible(); + await expect(page.getByText('super-secret')).toHaveCount(0); + await expect(page.getByText('sk-live-secret')).toHaveCount(0); + await expect(page.getByText('tok-secret')).toHaveCount(0); + + await query.fill('nomatch'); + await page.getByRole('button', { name: 'Search' }).click(); + await expect(page.getByText('No matches')).toBeVisible(); + + await query.fill('fail'); + await page.getByRole('button', { name: 'Search' }).click(); + await expect(page.getByText('Search failed')).toBeVisible(); + await expect( + page.getByText( + 'The embeddings service is temporarily unavailable. Check that workers are enabled and the provider is reachable, then retry.' + ) + ).toBeVisible(); + await expect(page.getByText('No matches')).toHaveCount(0); + await expect( + page.getByRole('cell', { name: 'Published guide' }) + ).toHaveCount(0); + }); + + test('searches when workers are off', async ({ page }) => { + await resetMock('workers-off'); + await page.goto('/embeddings/test'); + await expect( + page.getByRole('heading', { name: 'Workers disabled' }) + ).toBeVisible(); + await page.getByLabel('Query').fill('published guide'); + await expect(page.getByRole('button', { name: 'Search' })).toBeEnabled(); + await page.getByRole('button', { name: 'Search' }).click(); + await expect( + page.getByRole('cell', { name: 'Published guide' }) + ).toBeVisible(); + }); +}); diff --git a/e2e/session.spec.ts b/e2e/session.spec.ts new file mode 100644 index 000000000..624a65ca4 --- /dev/null +++ b/e2e/session.spec.ts @@ -0,0 +1,39 @@ +import { expect, test } from '@playwright/test'; +import { resetMock, revokeMockTokens } from './helpers/mock.ts'; + +test.describe('session handling', () => { + test('redirects unauthenticated visits to login', async ({ browser }) => { + const context = await browser.newContext({ + storageState: { cookies: [], origins: [] }, + }); + const page = await context.newPage(); + await page.goto('/embeddings'); + await expect(page).toHaveURL(/\/login/); + await context.close(); + }); + + test('redirects to login when the Admin API returns 401', async ({ + page, + }) => { + await resetMock('ready'); + await page.goto('/embeddings/configs/cfg_product'); + await expect( + page.getByRole('heading', { name: 'Matching index' }) + ).toBeVisible(); + await revokeMockTokens(); + const timeoutUrls: string[] = []; + page.on('request', request => { + if (request.url().includes('session-timeout=true')) { + timeoutUrls.push(request.url()); + } + }); + await page.goto('/embeddings/configs/cfg_product'); + await expect(page).toHaveURL(/\/login/); + expect(timeoutUrls.length).toBeGreaterThan(0); + await expect(page.getByRole('heading', { name: 'Sign In' })).toBeVisible(); + const cookies = await page.context().cookies(); + expect(cookies.some(cookie => cookie.name.endsWith('AccessToken'))).toBe( + false + ); + }); +}); diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts new file mode 100644 index 000000000..d836e81c5 --- /dev/null +++ b/e2e/settings.spec.ts @@ -0,0 +1,91 @@ +import { expect, test } from '@playwright/test'; +import { inspectMock, resetMock } from './helpers/mock.ts'; +import { exactText } from './helpers/ui.ts'; +import { MOCK_ADMIN_ORIGIN, STORED_API_KEY } from './mock-admin/constants.ts'; + +test.describe('embeddings settings', () => { + test('keeps the stored API key when a redacted update omits it', async ({ + page, + }) => { + await resetMock('ready'); + await page.goto('/embeddings/settings'); + await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible(); + await expect(page.getByRole('switch', { name: 'Workers' })).toBeVisible(); + await expect( + page.getByText('Workload is serving. Workers can run when enabled.') + ).toBeVisible(); + await expect( + page.getByText( + 'A key is already stored. Leave blank to keep it, or enter a replacement.' + ) + ).toBeVisible(); + await expect(page.getByLabel('Provider')).toHaveValue('openai-compatible'); + await expect(page.getByText('Allowed hosts')).toHaveCount(0); + await expect(page.getByText('Require gRPC key')).toHaveCount(0); + await expect(page.getByRole('heading', { name: 'Models' })).toBeVisible(); + await page.getByRole('button', { name: 'Edit' }).click(); + await expect(page.getByLabel('Model name')).toBeVisible(); + await page.getByLabel('Dimensions').fill('3072'); + await expect(page.getByLabel('API key')).toHaveValue(''); + expect(await page.content()).not.toContain(STORED_API_KEY); + await page.getByRole('button', { name: 'Save' }).click(); + await expect(exactText(page, 'Embeddings settings saved')).toBeVisible(); + const inspect = await inspectMock(); + expect(inspect.storedApiKeyConfigured).toBe(true); + expect(inspect.lastSettingsPatchHadApiKey).toBe(false); + }); + + test('adds a model and keeps the selected default until it is cleared', async ({ + page, + }) => { + await resetMock('ready'); + await page.goto('/embeddings/settings'); + await page.getByRole('button', { name: 'Edit' }).click(); + await expect( + page.getByRole('button', { + name: 'Clear or change the default model before removing this row', + }) + ).toBeDisabled(); + await page.getByRole('button', { name: 'Add model' }).click(); + const names = page.getByLabel('Model name'); + const dimensions = page.getByLabel('Dimensions'); + await expect(names).toHaveCount(2); + await names.nth(1).fill('text-embedding-3-large'); + await dimensions.nth(1).fill('3072'); + await expect( + page.getByRole('button', { name: 'Remove text-embedding-3-large' }) + ).toBeEnabled(); + await page.getByRole('button', { name: 'Advanced limits' }).click(); + await expect( + page.getByText( + 'Production gRPC access uses the deployment GRPC_KEY. It is not configured here.' + ) + ).toBeVisible(); + await page.getByRole('button', { name: 'Save' }).click(); + await expect(exactText(page, 'Embeddings settings saved')).toBeVisible(); + }); + + test('toggles workers from module config without waiting for serving', async ({ + page, + }) => { + await resetMock('workers-off'); + await page.goto('/embeddings/settings'); + const workers = page.getByRole('switch', { name: 'Workers' }); + await expect(workers).not.toBeChecked(); + await expect( + page.getByText('Workload is serving. Workers can run when enabled.') + ).toBeVisible(); + await workers.click(); + await expect(page.getByRole('alertdialog')).toBeVisible(); + await page.getByRole('button', { name: 'Proceed' }).click(); + await expect(exactText(page, 'Embeddings settings saved')).toBeVisible(); + await expect(workers).toBeChecked(); + const inspect = await inspectMock(); + expect(inspect.workersEnabled).toBe(true); + }); + + test('rejects mock test-control routes without the control header', async () => { + const response = await fetch(`${MOCK_ADMIN_ORIGIN}/__test__/state`); + expect(response.status).toBe(403); + }); +}); diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts new file mode 100644 index 000000000..6a6ffa38e --- /dev/null +++ b/e2e/smoke.spec.ts @@ -0,0 +1,53 @@ +import { expect, test } from '@playwright/test'; +import { openEmbeddingsNav, switchToDarkTheme } from './helpers/ui.ts'; +import { resetMock } from './helpers/mock.ts'; + +test.describe('viewport and theme smoke', () => { + test('renders embeddings in light and dark', async ({ page }, testInfo) => { + await resetMock('ready'); + await page.goto('/embeddings'); + await expect( + page.getByRole('heading', { name: 'Readiness' }) + ).toBeVisible(); + const light = await page.screenshot({ fullPage: false }); + await testInfo.attach('overview-light', { + body: light, + contentType: 'image/png', + }); + + await switchToDarkTheme(page); + await expect(page.locator('html')).toHaveClass(/dark/); + const dark = await page.screenshot({ fullPage: false }); + await testInfo.attach('overview-dark', { + body: dark, + contentType: 'image/png', + }); + }); + + test('keeps embeddings reachable on a narrow viewport', async ({ page }) => { + await resetMock('ready'); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/embeddings'); + await expect( + page.getByRole('heading', { name: 'Readiness' }) + ).toBeVisible(); + await page.keyboard.press('ControlOrMeta+b'); + await openEmbeddingsNav(page); + }); + + test('keeps configs readable on a narrow viewport', async ({ page }) => { + await resetMock('ready'); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/embeddings/configs'); + await expect(page.getByRole('heading', { name: 'Configs' })).toBeVisible(); + await expect( + page + .getByRole('definition') + .filter({ hasText: 'openai-compatible/text-embedding-3-small' }) + .first() + ).toBeVisible(); + await expect( + page.getByRole('term').filter({ hasText: 'Index' }).first() + ).toBeVisible(); + }); +}); diff --git a/eslint.config.mjs b/eslint.config.mjs index ed7def39c..b99d34e07 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -15,6 +15,9 @@ const eslintConfig = [ '**/build/**', '**/coverage/**', '**/.turbo/**', + '**/test-results/**', + '**/playwright-report/**', + '**/e2e/.auth/**', ], }, ...coreWebVitals, diff --git a/next-env.d.ts b/next-env.d.ts deleted file mode 100644 index c4b7818fb..000000000 --- a/next-env.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/// -/// -import "./.next/dev/types/routes.d.ts"; - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/package.json b/package.json index e5f90b63c..cf8e97855 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,10 @@ "build": "next build", "start": "next start -p 8080", "lint": "eslint .", + "test": "vitest run", + "test:watch": "vitest", + "test:e2e": "playwright test", + "test:e2e:install": "playwright install chromium", "test:semantic-colors": "node --experimental-strip-types --test src/lib/semantic-colors.test.ts src/lib/reactflow-edge-colors.test.ts", "test:schema-fields": "node --experimental-strip-types --test src/lib/database/schema-field-definition.test.ts src/lib/database/system-schema-fields.test.ts", "build:docker": "docker build --platform linux/amd64 -t ghcr.io/conduitplatform/conduit-ui:latest .", @@ -89,20 +93,26 @@ "devDependencies": { "@commitlint/cli": "^20.0.0", "@commitlint/config-conventional": "^20.0.0", + "@playwright/test": "^1.63.0", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", "@types/jsonwebtoken": "^9.0.10", "@types/lodash": "^4.17.24", "@types/node": "^24.0.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.1.1", "cz-conventional-changelog": "^3.3.0", "eslint": "^9.0.0", "eslint-config-next": "^16.2.2", "husky": "^9.1.7", + "jsdom": "^30.0.1", "lint-staged": "^16.0.0", "prettier": "^3.8.1", "standard-version": "^9.5.0", "turbo": "^2.9.3", - "typescript": "^6.0.0" + "typescript": "^6.0.0", + "vitest": "^5.0.0" }, "lint-staged": { "**/*": "prettier --write --ignore-unknown" diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 000000000..db69a1f91 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,88 @@ +import { defineConfig, devices } from '@playwright/test'; +import { + E2E_ENV_NAME, + E2E_MASTER_KEY, + MOCK_ADMIN_HOST, + MOCK_ADMIN_ORIGIN, + MOCK_ADMIN_PORT, +} from './e2e/mock-admin/constants.ts'; + +const uiPort = Number(process.env.E2E_UI_PORT ?? '3080'); +const uiOrigin = `http://127.0.0.1:${uiPort}`; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: false, + workers: 1, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? [['github'], ['list']] : 'list', + timeout: 60_000, + expect: { timeout: 10_000 }, + use: { + baseURL: uiOrigin, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'off', + }, + webServer: [ + { + command: `NODE_NO_WARNINGS=1 node --experimental-strip-types e2e/mock-admin/start.mts`, + url: `${MOCK_ADMIN_ORIGIN}/ready`, + reuseExistingServer: !process.env.CI, + timeout: 30_000, + env: { + MOCK_ADMIN_PORT: String(MOCK_ADMIN_PORT), + }, + }, + { + command: `pnpm exec next dev --turbopack -p ${uiPort} --hostname 127.0.0.1`, + url: uiOrigin, + reuseExistingServer: !process.env.CI, + timeout: 180_000, + env: { + API_BASE_URL: MOCK_ADMIN_ORIGIN, + MASTER_KEY: E2E_MASTER_KEY, + ENVIRONMENT_MODE: 'single', + DEFAULT_ENVIRONMENT: E2E_ENV_NAME, + NEXT_TELEMETRY_DISABLED: '1', + NEXT_DISABLE_DEVTOOLS: '1', + }, + }, + ], + projects: [ + { name: 'setup', testMatch: /auth\.setup\.ts/ }, + { + name: 'chromium', + dependencies: ['setup'], + testMatch: [ + /navigation\.spec\.ts/, + /overview\.spec\.ts/, + /configs\.spec\.ts/, + /backfills\.spec\.ts/, + /search\.spec\.ts/, + /settings\.spec\.ts/, + /session\.spec\.ts/, + /keyboard\.spec\.ts/, + /smoke\.spec\.ts/, + ], + use: { + ...devices['Desktop Chrome'], + storageState: 'e2e/.auth/user.json', + }, + }, + { + name: 'mobile', + dependencies: ['setup'], + testMatch: /smoke\.spec\.ts/, + use: { + ...devices['Pixel 5'], + storageState: 'e2e/.auth/user.json', + }, + }, + ], + outputDir: 'test-results', + metadata: { + mockHost: MOCK_ADMIN_HOST, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44a6c2ade..d7b868cfa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -155,7 +155,7 @@ importers: version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next: specifier: ^16.2.2 - version: 16.2.2(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.2.2(@babel/core@7.29.0)(@playwright/test@1.63.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -220,6 +220,15 @@ importers: '@commitlint/config-conventional': specifier: ^20.0.0 version: 20.5.0 + '@playwright/test': + specifier: ^1.63.0 + version: 1.63.0 + '@testing-library/jest-dom': + specifier: ^7.0.1 + version: 7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.0(@types/node@24.12.2)(happy-dom@20.9.0)(jsdom@30.0.1)(vite@8.2.2(@types/node@24.12.2)(esbuild@0.27.5)(jiti@2.6.1)(yaml@2.8.3))) + '@testing-library/react': + specifier: ^16.3.3 + version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@types/jsonwebtoken': specifier: ^9.0.10 version: 9.0.10 @@ -235,6 +244,9 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^6.1.1 + version: 6.1.1(vite@8.2.2(@types/node@24.12.2)(esbuild@0.27.5)(jiti@2.6.1)(yaml@2.8.3)) cz-conventional-changelog: specifier: ^3.3.0 version: 3.3.0(@types/node@24.12.2)(typescript@6.0.2) @@ -247,6 +259,9 @@ importers: husky: specifier: ^9.1.7 version: 9.1.7 + jsdom: + specifier: ^30.0.1 + version: 30.0.1 lint-staged: specifier: ^16.0.0 version: 16.4.0 @@ -262,8 +277,17 @@ importers: typescript: specifier: ^6.0.0 version: 6.0.2 + vitest: + specifier: ^5.0.0 + version: 5.0.0(@types/node@24.12.2)(happy-dom@20.9.0)(jsdom@30.0.1)(vite@8.2.2(@types/node@24.12.2)(esbuild@0.27.5)(jiti@2.6.1)(yaml@2.8.3)) packages: + '@adobe/css-tools@4.5.0': + resolution: + { + integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==, + } + '@alloc/quick-lru@5.2.0': resolution: { @@ -271,6 +295,20 @@ packages: } engines: { node: '>=10' } + '@asamuzakjp/css-color@6.0.7': + resolution: + { + integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==, + } + engines: { node: ^22.13.0 || >=24.0.0 } + + '@asamuzakjp/dom-selector@8.3.2': + resolution: + { + integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==, + } + engines: { node: ^22.13.0 || >=24.0.0 } + '@azure/abort-controller@2.1.2': resolution: { @@ -485,6 +523,13 @@ packages: } engines: { node: '>=6.9.0' } + '@bramus/specificity@2.4.2': + resolution: + { + integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==, + } + hasBin: true + '@commitlint/cli@20.5.0': resolution: { @@ -620,6 +665,60 @@ packages: conventional-commits-parser: optional: true + '@csstools/color-helpers@6.1.1': + resolution: + { + integrity: sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==, + } + engines: { node: '>=20.19.0' } + + '@csstools/css-calc@3.3.0': + resolution: + { + integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==, + } + engines: { node: '>=20.19.0' } + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.2.2': + resolution: + { + integrity: sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==, + } + engines: { node: '>=20.19.0' } + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: + { + integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==, + } + engines: { node: '>=20.19.0' } + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.12': + resolution: + { + integrity: sha512-3vLQK+dXxhBMR2Wx99PTCifE+vHtW2ndZWyla8yK813ev6oGhyn8Lja8jCyGAWTJ+LEYZK7EVtJxrDj8ztevJw==, + } + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: + { + integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==, + } + engines: { node: '>=20.19.0' } + '@date-fns/tz@1.4.1': resolution: { @@ -931,6 +1030,18 @@ packages: } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + '@exodus/bytes@1.15.1': + resolution: + { + integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==, + } + engines: { node: ^20.19.0 || ^22.12.0 || >=24.0.0 } + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@floating-ui/core@1.6.8': resolution: { @@ -1402,6 +1513,20 @@ packages: } engines: { node: '>=12.4.0' } + '@oxc-project/types@0.149.0': + resolution: + { + integrity: sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==, + } + + '@playwright/test@1.63.0': + resolution: + { + integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==, + } + engines: { node: '>=20' } + hasBin: true + '@radix-ui/number@1.1.1': resolution: { @@ -2230,6 +2355,138 @@ packages: integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==, } + '@rolldown/binding-android-arm-eabi@1.2.8': + resolution: + { + integrity: sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.8': + resolution: + { + integrity: sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.8': + resolution: + { + integrity: sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.8': + resolution: + { + integrity: sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.8': + resolution: + { + integrity: sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.8': + resolution: + { + integrity: sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.8': + resolution: + { + integrity: sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.2.8': + resolution: + { + integrity: sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.2.8': + resolution: + { + integrity: sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.2.8': + resolution: + { + integrity: sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.2.8': + resolution: + { + integrity: sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.2.8': + resolution: + { + integrity: sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [x64] + os: [linux] + + '@rolldown/binding-win32-arm64-msvc@1.2.8': + resolution: + { + integrity: sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.8': + resolution: + { + integrity: sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: + { + integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==, + } + '@rtsao/scc@1.1.0': resolution: { @@ -2437,6 +2694,44 @@ packages: } engines: { node: '>=12' } + '@testing-library/dom@10.4.1': + resolution: + { + integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==, + } + engines: { node: '>=18' } + + '@testing-library/jest-dom@7.0.1': + resolution: + { + integrity: sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==, + } + engines: { node: '>=22', npm: '>=6', yarn: '>=1' } + peerDependencies: + '@testing-library/dom': '>=10 <11' + vitest: '>= 0.32' + peerDependenciesMeta: + vitest: + optional: true + + '@testing-library/react@16.3.3': + resolution: + { + integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==, + } + engines: { node: '>=18' } + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^19.2.14 + '@types/react-dom': ^19.2.3 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@tiptap/core@3.22.3': resolution: { @@ -2764,6 +3059,18 @@ packages: cpu: [arm64] os: [win32] + '@types/aria-query@5.0.4': + resolution: + { + integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==, + } + + '@types/chai@5.2.3': + resolution: + { + integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==, + } + '@types/cors@2.8.19': resolution: { @@ -2956,6 +3263,12 @@ packages: integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==, } + '@types/deep-eql@4.0.2': + resolution: + { + integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==, + } + '@types/estree@1.0.8': resolution: { @@ -3224,6 +3537,45 @@ packages: integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==, } + '@vitejs/plugin-react@6.1.1': + resolution: + { + integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + oxc-transform-react: ^0.145.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + oxc-transform-react: + optional: true + + '@vitest/mocker@5.0.0': + resolution: + { + integrity: sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==, + } + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.0.0': + resolution: + { + integrity: sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==, + } + JSONStream@1.3.5: resolution: { @@ -3332,6 +3684,13 @@ packages: } engines: { node: '>=8' } + ansi-styles@5.2.0: + resolution: + { + integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==, + } + engines: { node: '>=10' } + ansi-styles@6.2.3: resolution: { @@ -3352,6 +3711,12 @@ packages: } engines: { node: '>=10' } + aria-query@5.3.0: + resolution: + { + integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==, + } + aria-query@5.3.2: resolution: { @@ -3428,6 +3793,13 @@ packages: } engines: { node: '>=0.10.0' } + assertion-error@2.0.1: + resolution: + { + integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==, + } + engines: { node: '>=12' } + ast-types-flow@0.0.8: resolution: { @@ -3527,6 +3899,12 @@ packages: engines: { node: '>=6.0.0' } hasBin: true + bidi-js@1.1.0: + resolution: + { + integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==, + } + bl@4.1.0: resolution: { @@ -3647,6 +4025,13 @@ packages: integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==, } + chai@6.2.2: + resolution: + { + integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==, + } + engines: { node: '>=18' } + chalk@2.4.2: resolution: { @@ -4123,6 +4508,12 @@ packages: } engines: { node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0 } + css.escape@1.5.1: + resolution: + { + integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==, + } + csstype@3.2.3: resolution: { @@ -4269,6 +4660,13 @@ packages: } engines: { node: '>=8' } + data-urls@7.0.0: + resolution: + { + integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==, + } + engines: { node: ^20.19.0 || ^22.12.0 || >=24.0.0 } + data-view-buffer@1.0.2: resolution: { @@ -4498,6 +4896,18 @@ packages: } engines: { node: '>=0.10.0' } + dom-accessibility-api@0.5.16: + resolution: + { + integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==, + } + + dom-accessibility-api@0.6.3: + resolution: + { + integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==, + } + dom-serializer@2.0.0: resolution: { @@ -4638,12 +5048,19 @@ packages: } engines: { node: '>=0.12' } - env-paths@2.2.1: + entities@8.1.0: resolution: { - integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==, + integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==, } - engines: { node: '>=6' } + engines: { node: '>=20.19.0' } + + env-paths@2.2.1: + resolution: + { + integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==, + } + engines: { node: '>=6' } env-paths@3.0.0: resolution: @@ -4693,6 +5110,12 @@ packages: } engines: { node: '>= 0.4' } + es-module-lexer@2.3.2: + resolution: + { + integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==, + } + es-object-atoms@1.1.1: resolution: { @@ -4923,6 +5346,12 @@ packages: } engines: { node: '>=4.0' } + estree-walker@3.0.3: + resolution: + { + integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==, + } + esutils@2.0.3: resolution: { @@ -4943,6 +5372,13 @@ packages: } engines: { node: '>=0.10.0' } + expect-type@1.4.0: + resolution: + { + integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==, + } + engines: { node: '>=12.0.0' } + extend@3.0.2: resolution: { @@ -5169,6 +5605,14 @@ packages: integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==, } + fsevents@2.3.3: + resolution: + { + integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==, + } + engines: { node: ^8.16.0 || ^10.6.0 || >=11.0.0 } + os: [darwin] + function-bind@1.1.2: resolution: { @@ -5548,6 +5992,13 @@ packages: } engines: { node: '>=10' } + html-encoding-sniffer@6.0.0: + resolution: + { + integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==, + } + engines: { node: ^20.19.0 || ^22.12.0 || >=24.0.0 } + html-to-text@9.0.5: resolution: { @@ -5915,6 +6366,12 @@ packages: } engines: { node: '>=12' } + is-potential-custom-element-name@1.0.1: + resolution: + { + integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==, + } + is-regex@1.2.1: resolution: { @@ -6078,6 +6535,18 @@ packages: } hasBin: true + jsdom@30.0.1: + resolution: + { + integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==, + } + engines: { node: ^22.22.2 || ^24.15.0 || >=26.0.0 } + peerDependencies: + canvas: ^3.2.3 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: { @@ -6243,6 +6712,15 @@ packages: cpu: [arm64] os: [android] + lightningcss-android-arm64@1.33.0: + resolution: + { + integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==, + } + engines: { node: '>= 12.0.0' } + cpu: [arm64] + os: [android] + lightningcss-darwin-arm64@1.32.0: resolution: { @@ -6252,6 +6730,15 @@ packages: cpu: [arm64] os: [darwin] + lightningcss-darwin-arm64@1.33.0: + resolution: + { + integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==, + } + engines: { node: '>= 12.0.0' } + cpu: [arm64] + os: [darwin] + lightningcss-darwin-x64@1.32.0: resolution: { @@ -6261,6 +6748,15 @@ packages: cpu: [x64] os: [darwin] + lightningcss-darwin-x64@1.33.0: + resolution: + { + integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==, + } + engines: { node: '>= 12.0.0' } + cpu: [x64] + os: [darwin] + lightningcss-freebsd-x64@1.32.0: resolution: { @@ -6270,6 +6766,15 @@ packages: cpu: [x64] os: [freebsd] + lightningcss-freebsd-x64@1.33.0: + resolution: + { + integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==, + } + engines: { node: '>= 12.0.0' } + cpu: [x64] + os: [freebsd] + lightningcss-linux-arm-gnueabihf@1.32.0: resolution: { @@ -6279,6 +6784,15 @@ packages: cpu: [arm] os: [linux] + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: + { + integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==, + } + engines: { node: '>= 12.0.0' } + cpu: [arm] + os: [linux] + lightningcss-linux-arm64-gnu@1.32.0: resolution: { @@ -6288,6 +6802,15 @@ packages: cpu: [arm64] os: [linux] + lightningcss-linux-arm64-gnu@1.33.0: + resolution: + { + integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==, + } + engines: { node: '>= 12.0.0' } + cpu: [arm64] + os: [linux] + lightningcss-linux-arm64-musl@1.32.0: resolution: { @@ -6297,6 +6820,15 @@ packages: cpu: [arm64] os: [linux] + lightningcss-linux-arm64-musl@1.33.0: + resolution: + { + integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==, + } + engines: { node: '>= 12.0.0' } + cpu: [arm64] + os: [linux] + lightningcss-linux-x64-gnu@1.32.0: resolution: { @@ -6306,6 +6838,15 @@ packages: cpu: [x64] os: [linux] + lightningcss-linux-x64-gnu@1.33.0: + resolution: + { + integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==, + } + engines: { node: '>= 12.0.0' } + cpu: [x64] + os: [linux] + lightningcss-linux-x64-musl@1.32.0: resolution: { @@ -6315,6 +6856,15 @@ packages: cpu: [x64] os: [linux] + lightningcss-linux-x64-musl@1.33.0: + resolution: + { + integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==, + } + engines: { node: '>= 12.0.0' } + cpu: [x64] + os: [linux] + lightningcss-win32-arm64-msvc@1.32.0: resolution: { @@ -6324,6 +6874,15 @@ packages: cpu: [arm64] os: [win32] + lightningcss-win32-arm64-msvc@1.33.0: + resolution: + { + integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==, + } + engines: { node: '>= 12.0.0' } + cpu: [arm64] + os: [win32] + lightningcss-win32-x64-msvc@1.32.0: resolution: { @@ -6333,6 +6892,15 @@ packages: cpu: [x64] os: [win32] + lightningcss-win32-x64-msvc@1.33.0: + resolution: + { + integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==, + } + engines: { node: '>= 12.0.0' } + cpu: [x64] + os: [win32] + lightningcss@1.32.0: resolution: { @@ -6340,6 +6908,13 @@ packages: } engines: { node: '>= 12.0.0' } + lightningcss@1.33.0: + resolution: + { + integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==, + } + engines: { node: '>= 12.0.0' } + lines-and-columns@1.2.4: resolution: { @@ -6565,6 +7140,13 @@ packages: } engines: { node: 20 || >=22 } + lru-cache@11.5.2: + resolution: + { + integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==, + } + engines: { node: 20 || >=22 } + lru-cache@5.1.1: resolution: { @@ -6593,12 +7175,25 @@ packages: } engines: { node: '>=12' } + lz-string@1.5.0: + resolution: + { + integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==, + } + hasBin: true + magic-string@0.30.21: resolution: { integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==, } + magic-string@1.2.3: + resolution: + { + integrity: sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==, + } + map-obj@1.0.1: resolution: { @@ -6867,6 +7462,14 @@ packages: engines: { node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1 } hasBin: true + nanoid@3.3.18: + resolution: + { + integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==, + } + engines: { node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1 } + hasBin: true + natural-compare@1.4.0: resolution: { @@ -7015,6 +7618,13 @@ packages: } engines: { node: '>= 0.4' } + obug@2.2.1: + resolution: + { + integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==, + } + engines: { node: '>=12.20.0' } + once@1.4.0: resolution: { @@ -7192,6 +7802,12 @@ packages: integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==, } + parse5@8.0.1: + resolution: + { + integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==, + } + parseley@0.12.1: resolution: { @@ -7278,6 +7894,13 @@ packages: } engines: { node: '>=12' } + picomatch@4.0.7: + resolution: + { + integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==, + } + engines: { node: '>=12' } + pify@2.3.0: resolution: { @@ -7292,6 +7915,22 @@ packages: } engines: { node: '>=4' } + playwright-core@1.63.0: + resolution: + { + integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==, + } + engines: { node: '>=20' } + hasBin: true + + playwright@1.63.0: + resolution: + { + integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==, + } + engines: { node: '>=20' } + hasBin: true + possible-typed-array-names@1.0.0: resolution: { @@ -7306,6 +7945,13 @@ packages: } engines: { node: ^10 || ^12 || >=14 } + postcss@8.5.28: + resolution: + { + integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==, + } + engines: { node: ^10 || ^12 || >=14 } + postcss@8.5.8: resolution: { @@ -7328,6 +7974,13 @@ packages: engines: { node: '>=14' } hasBin: true + pretty-format@27.5.1: + resolution: + { + integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==, + } + engines: { node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0 } + prismjs@1.30.0: resolution: { @@ -7590,6 +8243,12 @@ packages: integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==, } + react-is@17.0.2: + resolution: + { + integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==, + } + react-is@19.2.4: resolution: { @@ -7899,6 +8558,14 @@ packages: integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==, } + rolldown@1.2.8: + resolution: + { + integrity: sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + hasBin: true + rope-sequence@1.3.4: resolution: { @@ -7970,6 +8637,13 @@ packages: integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==, } + saxes@6.0.0: + resolution: + { + integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==, + } + engines: { node: '>=v12.22.7' } + scheduler@0.27.0: resolution: { @@ -8080,6 +8754,12 @@ packages: } engines: { node: '>= 0.4' } + siginfo@2.0.0: + resolution: + { + integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==, + } + signal-exit@3.0.7: resolution: { @@ -8189,6 +8869,12 @@ packages: integrity: sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==, } + stackback@0.0.2: + resolution: + { + integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==, + } + standard-version@9.5.0: resolution: { @@ -8197,6 +8883,12 @@ packages: engines: { node: '>=10' } hasBin: true + std-env@4.2.0: + resolution: + { + integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==, + } + stdin-discarder@0.2.2: resolution: { @@ -8396,6 +9088,12 @@ packages: } engines: { node: '>= 0.4' } + symbol-tree@3.2.4: + resolution: + { + integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==, + } + tagged-tag@1.0.0: resolution: { @@ -8461,6 +9159,13 @@ packages: integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==, } + tinybench@6.1.4: + resolution: + { + integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==, + } + engines: { node: '>=20.0.0' } + tinyexec@1.0.4: resolution: { @@ -8468,6 +9173,13 @@ packages: } engines: { node: '>=18' } + tinyexec@1.3.0: + resolution: + { + integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==, + } + engines: { node: '>=18' } + tinyglobby@0.2.15: resolution: { @@ -8475,6 +9187,26 @@ packages: } engines: { node: '>=12.0.0' } + tinyglobby@0.2.17: + resolution: + { + integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==, + } + engines: { node: '>=12.0.0' } + + tldts-core@7.4.12: + resolution: + { + integrity: sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==, + } + + tldts@7.4.12: + resolution: + { + integrity: sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==, + } + hasBin: true + tmp@0.0.33: resolution: { @@ -8489,6 +9221,20 @@ packages: } engines: { node: '>=8.0' } + tough-cookie@6.0.2: + resolution: + { + integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==, + } + engines: { node: '>=16' } + + tr46@6.0.0: + resolution: + { + integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==, + } + engines: { node: '>=20' } + trim-lines@3.0.1: resolution: { @@ -8671,6 +9417,13 @@ packages: integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==, } + undici@8.10.2: + resolution: + { + integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==, + } + engines: { node: '>=22.19.0' } + unified@11.0.5: resolution: { @@ -8806,58 +9559,183 @@ packages: { integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==, } - peerDependencies: - react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + + vfile-location@5.0.3: + resolution: + { + integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==, + } + + vfile-message@4.0.2: + resolution: + { + integrity: sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==, + } + + vfile@6.0.3: + resolution: + { + integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==, + } + + victory-vendor@37.3.6: + resolution: + { + integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==, + } + + vite@8.2.2: + resolution: + { + integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 || ^0.5.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@5.0.0: + resolution: + { + integrity: sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==, + } + engines: { node: ^22.12.0 || ^24.0.0 || >=26.0.0 } + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.0 + '@vitest/browser-preview': 5.0.0 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.0 + '@vitest/coverage-v8': 5.0.0 + '@vitest/ui': 5.0.0 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-keyname@2.2.8: + resolution: + { + integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==, + } - vfile-location@5.0.3: + w3c-xmlserializer@5.0.0: resolution: { - integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==, + integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==, } + engines: { node: '>=18' } - vfile-message@4.0.2: + wcwidth@1.0.1: resolution: { - integrity: sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==, + integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==, } - vfile@6.0.3: + web-namespaces@2.0.1: resolution: { - integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==, + integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==, } - victory-vendor@37.3.6: + webidl-conversions@8.0.1: resolution: { - integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==, + integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==, } + engines: { node: '>=20' } - w3c-keyname@2.2.8: + whatwg-mimetype@3.0.0: resolution: { - integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==, + integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==, } + engines: { node: '>=12' } - wcwidth@1.0.1: + whatwg-mimetype@5.0.0: resolution: { - integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==, + integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==, } + engines: { node: '>=20' } - web-namespaces@2.0.1: + whatwg-url@16.0.1: resolution: { - integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==, + integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==, } + engines: { node: ^20.19.0 || ^22.12.0 || >=24.0.0 } - whatwg-mimetype@3.0.0: + whatwg-url@17.1.0: resolution: { - integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==, + integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==, } - engines: { node: '>=12' } + engines: { node: ^22.14.0 || >=24.0.0 } when-exit@2.1.5: resolution: @@ -8908,6 +9786,14 @@ packages: engines: { node: '>= 8' } hasBin: true + why-is-node-running@2.3.0: + resolution: + { + integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==, + } + engines: { node: '>=8' } + hasBin: true + word-wrap@1.2.5: resolution: { @@ -8978,6 +9864,19 @@ packages: } engines: { node: '>=18' } + xml-name-validator@5.0.0: + resolution: + { + integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==, + } + engines: { node: '>=18' } + + xmlchars@2.2.0: + resolution: + { + integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==, + } + xtend@4.0.2: resolution: { @@ -9094,8 +9993,25 @@ packages: } snapshots: + '@adobe/css-tools@4.5.0': {} + '@alloc/quick-lru@5.2.0': {} + '@asamuzakjp/css-color@6.0.7': + dependencies: + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.2.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.5.2 + + '@asamuzakjp/dom-selector@8.3.2': + dependencies: + bidi-js: 1.1.0 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + '@azure/abort-controller@2.1.2': dependencies: tslib: 2.8.1 @@ -9297,6 +10213,10 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@commitlint/cli@20.5.0(@types/node@24.12.2)(conventional-commits-parser@6.4.0)(typescript@6.0.2)': dependencies: '@commitlint/format': 20.5.0 @@ -9419,6 +10339,30 @@ snapshots: optionalDependencies: conventional-commits-parser: 6.4.0 + '@csstools/color-helpers@6.1.1': {} + + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.2.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.1 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.12(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@date-fns/tz@1.4.1': {} '@emnapi/runtime@1.9.2': @@ -9550,6 +10494,8 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@exodus/bytes@1.15.1': {} + '@floating-ui/core@1.6.8': dependencies: '@floating-ui/utils': 0.2.8 @@ -9780,6 +10726,12 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@oxc-project/types@0.149.0': {} + + '@playwright/test@1.63.0': + dependencies: + playwright: 1.63.0 + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -10527,6 +11479,50 @@ snapshots: '@remirror/core-constants@3.0.0': {} + '@rolldown/binding-android-arm-eabi@1.2.8': + optional: true + + '@rolldown/binding-android-arm64@1.2.8': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.8': + optional: true + + '@rolldown/binding-darwin-x64@1.2.8': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.8': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.8': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.8': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.8': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.8': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.8': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.8': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.8': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.8': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.8': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + '@rtsao/scc@1.1.0': {} '@selderee/plugin-htmlparser2@0.11.0': @@ -10629,6 +11625,39 @@ snapshots: '@tanstack/table-core@8.21.3': {} + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.29.2 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@7.0.1(@testing-library/dom@10.4.1)(vitest@5.0.0(@types/node@24.12.2)(happy-dom@20.9.0)(jsdom@30.0.1)(vite@8.2.2(@types/node@24.12.2)(esbuild@0.27.5)(jiti@2.6.1)(yaml@2.8.3)))': + dependencies: + '@adobe/css-tools': 4.5.0 + '@testing-library/dom': 10.4.1 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + optionalDependencies: + vitest: 5.0.0(@types/node@24.12.2)(happy-dom@20.9.0)(jsdom@30.0.1)(vite@8.2.2(@types/node@24.12.2)(esbuild@0.27.5)(jiti@2.6.1)(yaml@2.8.3)) + + '@testing-library/react@16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@babel/runtime': 7.29.2 + '@testing-library/dom': 10.4.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@tiptap/core@3.22.3(@tiptap/pm@3.22.3)': dependencies: '@tiptap/pm': 3.22.3 @@ -10850,6 +11879,13 @@ snapshots: '@turbo/windows-arm64@2.9.3': optional: true + '@types/aria-query@5.0.4': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/cors@2.8.19': dependencies: '@types/node': 24.12.2 @@ -10971,6 +12007,8 @@ snapshots: '@types/d3-transition': 3.0.9 '@types/d3-zoom': 3.0.8 + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.8': {} '@types/geojson@7946.0.16': {} @@ -11153,6 +12191,22 @@ snapshots: '@ungap/structured-clone@1.2.0': {} + '@vitejs/plugin-react@6.1.1(vite@8.2.2(@types/node@24.12.2)(esbuild@0.27.5)(jiti@2.6.1)(yaml@2.8.3))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.2.2(@types/node@24.12.2)(esbuild@0.27.5)(jiti@2.6.1)(yaml@2.8.3) + + '@vitest/mocker@5.0.0(vite@8.2.2(@types/node@24.12.2)(esbuild@0.27.5)(jiti@2.6.1)(yaml@2.8.3))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.0 + estree-walker: 3.0.3 + magic-string: 1.2.3 + optionalDependencies: + vite: 8.2.2(@types/node@24.12.2)(esbuild@0.27.5)(jiti@2.6.1)(yaml@2.8.3) + + '@vitest/spy@5.0.0': {} + JSONStream@1.3.5: dependencies: jsonparse: 1.3.1 @@ -11211,6 +12265,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} argparse@2.0.1: {} @@ -11219,6 +12275,10 @@ snapshots: dependencies: tslib: 2.8.1 + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + aria-query@5.3.2: {} array-buffer-byte-length@1.0.2: @@ -11292,6 +12352,8 @@ snapshots: arrify@1.0.1: {} + assertion-error@2.0.1: {} + ast-types-flow@0.0.8: {} asynckit@0.4.0: {} @@ -11333,6 +12395,10 @@ snapshots: baseline-browser-mapping@2.10.14: {} + bidi-js@1.1.0: + dependencies: + require-from-string: 2.0.2 + bl@4.1.0: dependencies: buffer: 5.7.1 @@ -11406,6 +12472,8 @@ snapshots: ccount@2.0.1: {} + chai@6.2.2: {} + chalk@2.4.2: dependencies: ansi-styles: 3.2.1 @@ -11733,6 +12801,8 @@ snapshots: mdn-data: 2.27.1 source-map-js: 1.2.1 + css.escape@1.5.1: {} + csstype@3.2.3: {} currency-codes@2.2.0: @@ -11822,6 +12892,13 @@ snapshots: dargs@7.0.0: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -11928,6 +13005,10 @@ snapshots: dependencies: esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -12017,6 +13098,8 @@ snapshots: entities@7.0.1: {} + entities@8.1.0: {} + env-paths@2.2.1: {} env-paths@3.0.0: {} @@ -12107,6 +13190,8 @@ snapshots: iterator.prototype: 1.1.5 safe-array-concat: 1.1.3 + es-module-lexer@2.3.2: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -12372,6 +13457,10 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} eventemitter3@5.0.1: {} @@ -12380,6 +13469,8 @@ snapshots: dependencies: homedir-polyfill: 1.0.3 + expect-type@1.4.0: {} + extend@3.0.2: {} external-editor@3.1.0: @@ -12422,6 +13513,10 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + figures@3.2.0: dependencies: escape-string-regexp: 1.0.5 @@ -12511,6 +13606,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.3: + optional: true + function-bind@1.1.2: {} function.prototype.name@1.1.8: @@ -12790,6 +13888,12 @@ snapshots: dependencies: lru-cache: 6.0.0 + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + html-to-text@9.0.5: dependencies: '@selderee/plugin-htmlparser2': 0.11.0 @@ -12985,6 +14089,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -13067,6 +14173,32 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@30.0.1: + dependencies: + '@asamuzakjp/css-color': 6.0.7 + '@asamuzakjp/dom-selector': 8.3.2 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.12(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 8.10.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 17.1.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -13154,36 +14286,69 @@ snapshots: lightningcss-android-arm64@1.32.0: optional: true + lightningcss-android-arm64@1.33.0: + optional: true + lightningcss-darwin-arm64@1.32.0: optional: true + lightningcss-darwin-arm64@1.33.0: + optional: true + lightningcss-darwin-x64@1.32.0: optional: true + lightningcss-darwin-x64@1.33.0: + optional: true + lightningcss-freebsd-x64@1.32.0: optional: true + lightningcss-freebsd-x64@1.33.0: + optional: true + lightningcss-linux-arm-gnueabihf@1.32.0: optional: true + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + lightningcss-linux-arm64-gnu@1.32.0: optional: true + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + lightningcss-linux-arm64-musl@1.32.0: optional: true + lightningcss-linux-arm64-musl@1.33.0: + optional: true + lightningcss-linux-x64-gnu@1.32.0: optional: true + lightningcss-linux-x64-gnu@1.33.0: + optional: true + lightningcss-linux-x64-musl@1.32.0: optional: true + lightningcss-linux-x64-musl@1.33.0: + optional: true + lightningcss-win32-arm64-msvc@1.32.0: optional: true + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + lightningcss-win32-x64-msvc@1.32.0: optional: true + lightningcss-win32-x64-msvc@1.33.0: + optional: true + lightningcss@1.32.0: dependencies: detect-libc: 2.1.2 @@ -13200,6 +14365,22 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + lines-and-columns@1.2.4: {} linkify-it@5.0.0: @@ -13318,6 +14499,8 @@ snapshots: lru-cache@11.3.5: {} + lru-cache@11.5.2: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -13332,10 +14515,16 @@ snapshots: luxon@3.7.2: {} + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magic-string@1.2.3: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + map-obj@1.0.1: {} map-obj@4.3.0: {} @@ -13473,6 +14662,8 @@ snapshots: nanoid@3.3.11: {} + nanoid@3.3.18: {} + natural-compare@1.4.0: {} negotiator@0.6.3: {} @@ -13484,7 +14675,7 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - next@16.2.2(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.2.2(@babel/core@7.29.0)(@playwright/test@1.63.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 16.2.2 '@swc/helpers': 0.5.15 @@ -13503,6 +14694,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.2.2 '@next/swc-win32-arm64-msvc': 16.2.2 '@next/swc-win32-x64-msvc': 16.2.2 + '@playwright/test': 1.63.0 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -13576,6 +14768,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + obug@2.2.1: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -13705,6 +14899,10 @@ snapshots: dependencies: entities: 4.5.0 + parse5@8.0.1: + dependencies: + entities: 8.1.0 + parseley@0.12.1: dependencies: leac: 0.6.0 @@ -13739,10 +14937,18 @@ snapshots: picomatch@4.0.4: {} + picomatch@4.0.7: {} + pify@2.3.0: {} pify@3.0.0: {} + playwright-core@1.63.0: {} + + playwright@1.63.0: + dependencies: + playwright-core: 1.63.0 + possible-typed-array-names@1.0.0: {} postcss@8.4.31: @@ -13751,6 +14957,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.28: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + postcss@8.5.8: dependencies: nanoid: 3.3.11 @@ -13761,6 +14973,12 @@ snapshots: prettier@3.8.1: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + prismjs@1.30.0: {} process-nextick-args@2.0.1: {} @@ -13960,6 +15178,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react-is@19.2.4: {} react-json-view-lite@2.5.0(react@19.2.4): @@ -14205,6 +15425,26 @@ snapshots: rfdc@1.4.1: {} + rolldown@1.2.8: + dependencies: + '@oxc-project/types': 0.149.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.8 + '@rolldown/binding-android-arm64': 1.2.8 + '@rolldown/binding-darwin-arm64': 1.2.8 + '@rolldown/binding-darwin-x64': 1.2.8 + '@rolldown/binding-freebsd-x64': 1.2.8 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.8 + '@rolldown/binding-linux-arm64-gnu': 1.2.8 + '@rolldown/binding-linux-arm64-musl': 1.2.8 + '@rolldown/binding-linux-ppc64-gnu': 1.2.8 + '@rolldown/binding-linux-s390x-gnu': 1.2.8 + '@rolldown/binding-linux-x64-gnu': 1.2.8 + '@rolldown/binding-linux-x64-musl': 1.2.8 + '@rolldown/binding-win32-arm64-msvc': 1.2.8 + '@rolldown/binding-win32-x64-msvc': 1.2.8 + rope-sequence@1.3.4: {} run-applescript@7.0.0: {} @@ -14244,6 +15484,10 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} selderee@0.11.0: @@ -14346,6 +15590,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -14420,6 +15666,8 @@ snapshots: dependencies: through: 2.3.8 + stackback@0.0.2: {} + standard-version@9.5.0: dependencies: chalk: 2.4.2 @@ -14437,6 +15685,8 @@ snapshots: stringify-package: 1.0.1 yargs: 16.2.0 + std-env@4.2.0: {} + stdin-discarder@0.2.2: {} stop-iteration-iterator@1.1.0: @@ -14569,6 +15819,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} + tagged-tag@1.0.0: {} tailwind-merge@3.5.0: {} @@ -14596,13 +15848,28 @@ snapshots: tiny-invariant@1.3.3: {} + tinybench@6.1.4: {} + tinyexec@1.0.4: {} + tinyexec@1.3.0: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tldts-core@7.4.12: {} + + tldts@7.4.12: + dependencies: + tldts-core: 7.4.12 + tmp@0.0.33: dependencies: os-tmpdir: 1.0.2 @@ -14611,6 +15878,14 @@ snapshots: dependencies: is-number: 7.0.0 + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.12 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + trim-lines@3.0.1: {} trim-newlines@3.0.1: {} @@ -14725,6 +16000,8 @@ snapshots: undici-types@7.16.0: {} + undici@8.10.2: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -14853,16 +16130,77 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 + vite@8.2.2(@types/node@24.12.2)(esbuild@0.27.5)(jiti@2.6.1)(yaml@2.8.3): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.8 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.12.2 + esbuild: 0.27.5 + fsevents: 2.3.3 + jiti: 2.6.1 + yaml: 2.8.3 + + vitest@5.0.0(@types/node@24.12.2)(happy-dom@20.9.0)(jsdom@30.0.1)(vite@8.2.2(@types/node@24.12.2)(esbuild@0.27.5)(jiti@2.6.1)(yaml@2.8.3)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.0(vite@8.2.2(@types/node@24.12.2)(esbuild@0.27.5)(jiti@2.6.1)(yaml@2.8.3)) + chai: 6.2.2 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.2.3 + obug: 2.2.1 + picomatch: 4.0.7 + std-env: 4.2.0 + tinybench: 6.1.4 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + vite: 8.2.2(@types/node@24.12.2)(esbuild@0.27.5)(jiti@2.6.1)(yaml@2.8.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.12.2 + happy-dom: 20.9.0 + jsdom: 30.0.1 + transitivePeerDependencies: + - msw + w3c-keyname@2.2.8: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + wcwidth@1.0.1: dependencies: defaults: 1.0.4 web-namespaces@2.0.1: {} + webidl-conversions@8.0.1: {} + whatwg-mimetype@3.0.0: {} + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + whatwg-url@17.1.0: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + when-exit@2.1.5: {} which-boxed-primitive@1.1.1: @@ -14914,6 +16252,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} wordwrap@1.0.0: {} @@ -14940,6 +16283,10 @@ snapshots: dependencies: is-wsl: 3.1.0 + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + xtend@4.0.2: {} y18n@5.0.8: {} diff --git a/src/app/(dashboard)/(modules)/embeddings/backfills/[id]/loading.tsx b/src/app/(dashboard)/(modules)/embeddings/backfills/[id]/loading.tsx new file mode 100644 index 000000000..f28212728 --- /dev/null +++ b/src/app/(dashboard)/(modules)/embeddings/backfills/[id]/loading.tsx @@ -0,0 +1,18 @@ +import { Skeleton } from '@/components/ui/skeleton'; + +export default function EmbeddingBackfillDetailLoading() { + return ( +
+
+
+ + +
+ +
+ + + +
+ ); +} diff --git a/src/app/(dashboard)/(modules)/embeddings/backfills/[id]/not-found.tsx b/src/app/(dashboard)/(modules)/embeddings/backfills/[id]/not-found.tsx new file mode 100644 index 000000000..8a06ef91a --- /dev/null +++ b/src/app/(dashboard)/(modules)/embeddings/backfills/[id]/not-found.tsx @@ -0,0 +1,33 @@ +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { FileQuestion } from 'lucide-react'; +import Link from 'next/link'; + +export default function EmbeddingBackfillNotFound() { + return ( +
+ + +
+ +
+ Backfill not found + + This backfill run does not exist or was removed. + +
+ + + +
+
+ ); +} diff --git a/src/app/(dashboard)/(modules)/embeddings/backfills/[id]/page.tsx b/src/app/(dashboard)/(modules)/embeddings/backfills/[id]/page.tsx new file mode 100644 index 000000000..d4977f95e --- /dev/null +++ b/src/app/(dashboard)/(modules)/embeddings/backfills/[id]/page.tsx @@ -0,0 +1,20 @@ +import { notFound } from 'next/navigation'; +import { BackfillDetail } from '@/components/embeddings/backfills/backfill-detail'; +import { getBackfill } from '@/lib/api/embeddings'; +import { BackfillRun, isEmbeddingsNotFound } from '@/lib/models/embeddings'; + +export default async function EmbeddingBackfillDetailPage(props: { + params: Promise<{ id: string }>; +}) { + const { id } = await props.params; + + let run: BackfillRun; + try { + run = await getBackfill(id); + } catch (error) { + if (isEmbeddingsNotFound(error)) notFound(); + throw error; + } + + return ; +} diff --git a/src/app/(dashboard)/(modules)/embeddings/backfills/loading.tsx b/src/app/(dashboard)/(modules)/embeddings/backfills/loading.tsx new file mode 100644 index 000000000..d5a86d43b --- /dev/null +++ b/src/app/(dashboard)/(modules)/embeddings/backfills/loading.tsx @@ -0,0 +1,21 @@ +import { Skeleton } from '@/components/ui/skeleton'; + +export default function EmbeddingBackfillsLoading() { + return ( +
+
+
+ + +
+ +
+
+ + + +
+ +
+ ); +} diff --git a/src/app/(dashboard)/(modules)/embeddings/backfills/page.tsx b/src/app/(dashboard)/(modules)/embeddings/backfills/page.tsx new file mode 100644 index 000000000..23a640462 --- /dev/null +++ b/src/app/(dashboard)/(modules)/embeddings/backfills/page.tsx @@ -0,0 +1,148 @@ +import Link from 'next/link'; +import { Info } from 'lucide-react'; +import { BackfillsTable } from '@/components/embeddings/backfills/backfills-table'; +import { StartBackfillDialog } from '@/components/embeddings/backfills/start-backfill-dialog'; +import { ErrorCard } from '@/components/error/ErrorCard'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { + PageActions, + PageDescription, + PageHeader, + PageTitle, +} from '@/components/ui/page-header'; +import { + getBackfills, + getEmbeddingConfigs, + getEmbeddingsSettings, + getEmbeddingsStatus, +} from '@/lib/api/embeddings'; +import { getDeclaredSchemas } from '@/lib/api/embeddings/indexes'; +import { toEmbeddingConfigOption } from '@/lib/models/embeddings/config'; +import { + mergeActiveBackfillRuns, + parseBackfillListParams, + settledError, + settledValue, + toBackfillListQuery, + uniqueSchemaNames, + workersEnabledFromStatus, + filterByEligibleSchemas, +} from '@/lib/models/embeddings'; + +export default async function EmbeddingBackfillsPage(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + const query = parseBackfillListParams(searchParams); + const listQuery = toBackfillListQuery(query); + + const [ + listResult, + queuedResult, + runningResult, + configsResult, + settingsResult, + statusResult, + schemasResult, + ] = await Promise.allSettled([ + getBackfills(listQuery), + query.state + ? Promise.resolve({ runs: [], count: 0 }) + : getBackfills({ ...listQuery, state: 'queued', skip: 0, limit: 100 }), + query.state + ? Promise.resolve({ runs: [], count: 0 }) + : getBackfills({ ...listQuery, state: 'running', skip: 0, limit: 100 }), + getEmbeddingConfigs(), + getEmbeddingsSettings(), + getEmbeddingsStatus(), + getDeclaredSchemas(), + ]); + + const listError = settledError(listResult); + if (listError) { + return ( + + Embeddings overview + + } + /> + ); + } + + const list = settledValue(listResult) ?? { runs: [], count: 0 }; + const runs = mergeActiveBackfillRuns({ + pageRuns: list.runs, + queuedRuns: settledValue(queuedResult)?.runs ?? [], + runningRuns: settledValue(runningResult)?.runs ?? [], + skip: query.skip, + state: query.state, + }); + const configs = settledValue(configsResult) ?? []; + const startableConfigs = filterByEligibleSchemas( + configs, + settledValue(schemasResult)?.schemas, + settledValue(schemasResult)?.systemSchemaNames + ); + const configOptions = startableConfigs.map(toEmbeddingConfigOption); + const schemas = uniqueSchemaNames(configs, runs); + if (query.schema && !schemas.includes(query.schema)) { + schemas.push(query.schema); + schemas.sort((left, right) => left.localeCompare(right)); + } + const settings = settledValue(settingsResult)?.config; + const status = settledValue(statusResult); + const workersEnabled = workersEnabledFromStatus({ + status, + settingsEnabled: settings?.enabled, + }); + + return ( +
+ +
+ Backfills + + Run history. Active runs appear first. + +
+ + + +
+ {workersEnabled === false ? ( + + + Workers disabled + + History stays available.{' '} + + Enable workers + {' '} + to process queued runs. + + + ) : null} + 0} + /> +
+ ); +} diff --git a/src/app/(dashboard)/(modules)/embeddings/configs/[id]/loading.tsx b/src/app/(dashboard)/(modules)/embeddings/configs/[id]/loading.tsx new file mode 100644 index 000000000..29e6f742a --- /dev/null +++ b/src/app/(dashboard)/(modules)/embeddings/configs/[id]/loading.tsx @@ -0,0 +1,21 @@ +import { Skeleton } from '@/components/ui/skeleton'; + +export default function EmbeddingConfigDetailLoading() { + return ( +
+
+
+ + +
+
+ + +
+
+ + + +
+ ); +} diff --git a/src/app/(dashboard)/(modules)/embeddings/configs/[id]/not-found.tsx b/src/app/(dashboard)/(modules)/embeddings/configs/[id]/not-found.tsx new file mode 100644 index 000000000..4cbcba912 --- /dev/null +++ b/src/app/(dashboard)/(modules)/embeddings/configs/[id]/not-found.tsx @@ -0,0 +1,34 @@ +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { FileQuestion } from 'lucide-react'; +import Link from 'next/link'; + +export default function EmbeddingConfigNotFound() { + return ( +
+ + +
+ +
+ Config not found + + This embedding configuration does not exist or was deleted. Vector + fields and indexes on the schema are retained. + +
+ + + +
+
+ ); +} diff --git a/src/app/(dashboard)/(modules)/embeddings/configs/[id]/page.tsx b/src/app/(dashboard)/(modules)/embeddings/configs/[id]/page.tsx new file mode 100644 index 000000000..677fd6cdb --- /dev/null +++ b/src/app/(dashboard)/(modules)/embeddings/configs/[id]/page.tsx @@ -0,0 +1,130 @@ +import { notFound } from 'next/navigation'; +import { ConfigDetail } from '@/components/embeddings/configs/config-detail'; +import { + getEmbeddingConfig, + getEmbeddingsCapabilities, + getEmbeddingsSettings, + getEmbeddingsStatus, +} from '@/lib/api/embeddings'; +import { + getDeclaredSchemas, + resolveIndexesBySchema, +} from '@/lib/api/embeddings/indexes'; +import { EmbeddingConfig } from '@/lib/models/embeddings/config'; +import { + isEmbeddingsNotFound, + settledError, + settledValue, +} from '@/lib/models/embeddings/errors'; +import { + canEnableEmbeddingConfig, + embeddingConfigEnableBlock, + findMatchingIndex, + toMatchingIndexView, +} from '@/lib/models/embeddings/index-state'; +import { + listEligibleSchemas, + toEmbeddingSchemaFormChoices, +} from '@/lib/models/embeddings/source-fields'; +import { workersEnabledFromStatus } from '@/lib/models/embeddings/overview-view'; +import { deriveEmbeddingsReadiness } from '@/lib/models/embeddings/readiness'; +import { + isConfigModelInCatalogue, + listConfiguredProviders, + MODEL_ABSENT_DETAIL, + SETTINGS_CTA_LABEL, + SETTINGS_HREF, +} from '@/lib/models/embeddings/config-catalogue'; + +export default async function EmbeddingConfigDetailPage(props: { + params: Promise<{ id: string }>; +}) { + const { id } = await props.params; + + const [configResult, schemasResult, settingsResult] = + await Promise.allSettled([ + getEmbeddingConfig(id), + getDeclaredSchemas(), + getEmbeddingsSettings(), + ]); + + if (configResult.status === 'rejected') { + if (isEmbeddingsNotFound(configResult.reason)) notFound(); + throw configResult.reason; + } + + const config: EmbeddingConfig = configResult.value; + const declared = settledValue(schemasResult); + const schemas = declared?.schemas; + const settings = settledValue(settingsResult)?.config; + + const [capabilitiesResult, statusResult, indexesResult] = + await Promise.allSettled([ + getEmbeddingsCapabilities(config.schemaName), + getEmbeddingsStatus(config.schemaName), + resolveIndexesBySchema([config.schemaName], declared), + ]); + const indexesBySchema = settledValue(indexesResult) ?? { + [config.schemaName]: 'unknown' as const, + }; + + const lookup = indexesBySchema[config.schemaName]; + const status = settledValue(statusResult); + const capabilities = + settledValue(capabilitiesResult)?.capabilities ?? status?.capabilities; + const formSchemas = toEmbeddingSchemaFormChoices( + listEligibleSchemas(schemas, declared?.systemSchemaNames), + { [config.schemaName]: config.sourceFields } + ); + + const workersEnabled = workersEnabledFromStatus({ + status, + settingsEnabled: settings?.enabled, + }); + const matchingIndex = + lookup && lookup !== 'unknown' + ? findMatchingIndex(config, lookup) + : undefined; + const enableAllowed = canEnableEmbeddingConfig({ + capabilities, + matchingIndex, + workersEnabled, + }); + const providers = listConfiguredProviders(settings); + const modelBlocked = + settings != null && !isConfigModelInCatalogue(config, providers); + const enableBlock = modelBlocked + ? { + reason: MODEL_ABSENT_DETAIL, + href: SETTINGS_HREF, + actionLabel: SETTINGS_CTA_LABEL, + } + : embeddingConfigEnableBlock({ + capabilities, + matchingIndex, + workersEnabled, + }); + const rows = deriveEmbeddingsReadiness({ + capabilities, + capabilitiesError: settledError(capabilitiesResult), + settings, + settingsError: settledError(settingsResult), + configs: [config], + indexesBySchema, + selectedConfigId: config._id, + workersEnabled, + }); + + return ( + + ); +} diff --git a/src/app/(dashboard)/(modules)/embeddings/configs/loading.tsx b/src/app/(dashboard)/(modules)/embeddings/configs/loading.tsx new file mode 100644 index 000000000..f285e0b11 --- /dev/null +++ b/src/app/(dashboard)/(modules)/embeddings/configs/loading.tsx @@ -0,0 +1,17 @@ +import { Skeleton } from '@/components/ui/skeleton'; + +export default function EmbeddingConfigsLoading() { + return ( +
+
+
+ + +
+ +
+ + +
+ ); +} diff --git a/src/app/(dashboard)/(modules)/embeddings/configs/new/loading.tsx b/src/app/(dashboard)/(modules)/embeddings/configs/new/loading.tsx new file mode 100644 index 000000000..ee5f0c1df --- /dev/null +++ b/src/app/(dashboard)/(modules)/embeddings/configs/new/loading.tsx @@ -0,0 +1,16 @@ +import { Skeleton } from '@/components/ui/skeleton'; + +export default function NewEmbeddingConfigLoading() { + return ( +
+
+ + +
+ + + + +
+ ); +} diff --git a/src/app/(dashboard)/(modules)/embeddings/configs/new/page.tsx b/src/app/(dashboard)/(modules)/embeddings/configs/new/page.tsx new file mode 100644 index 000000000..509d4644e --- /dev/null +++ b/src/app/(dashboard)/(modules)/embeddings/configs/new/page.tsx @@ -0,0 +1,62 @@ +import { CreateConfigForm } from '@/components/embeddings/configs/create-config-form'; +import { + PageDescription, + PageHeader, + PageTitle, +} from '@/components/ui/page-header'; +import { getDeclaredSchemas } from '@/lib/api/embeddings/indexes'; +import { getEmbeddingsSettings } from '@/lib/api/embeddings'; +import { settledValue } from '@/lib/models/embeddings/errors'; +import { OPENAI_COMPATIBLE_PROVIDER } from '@/lib/models/embeddings/settings'; +import { + listConfiguredProviders, + resolveCreateDefaults, +} from '@/lib/models/embeddings/config-catalogue'; +import { + listEligibleSchemas, + toEmbeddingSchemaFormChoices, +} from '@/lib/models/embeddings/source-fields'; + +export default async function NewEmbeddingConfigPage() { + const [schemasResult, settingsResult] = await Promise.allSettled([ + getDeclaredSchemas(), + getEmbeddingsSettings(), + ]); + + const schemas = toEmbeddingSchemaFormChoices( + listEligibleSchemas( + settledValue(schemasResult)?.schemas, + settledValue(schemasResult)?.systemSchemaNames + ) + ); + const settings = settledValue(settingsResult)?.config; + const providers = listConfiguredProviders(settings); + const defaults = resolveCreateDefaults({ + providers, + defaultProvider: settings?.defaultProvider || OPENAI_COMPATIBLE_PROVIDER, + defaultModel: settings + ? (settings.providers[settings.defaultProvider]?.defaultModel ?? '') + : '', + }); + + return ( +
+ +
+ New config + + Choose a schema and eligible string fields. The config stays + disabled until a matching index is queryable. + +
+
+ +
+ ); +} diff --git a/src/app/(dashboard)/(modules)/embeddings/configs/page.tsx b/src/app/(dashboard)/(modules)/embeddings/configs/page.tsx new file mode 100644 index 000000000..a0a663d41 --- /dev/null +++ b/src/app/(dashboard)/(modules)/embeddings/configs/page.tsx @@ -0,0 +1,95 @@ +import Link from 'next/link'; +import { Plus } from 'lucide-react'; +import { ConfigsTable } from '@/components/embeddings/configs/configs-table'; +import { ErrorCard } from '@/components/error/ErrorCard'; +import { Button } from '@/components/ui/button'; +import { + PageActions, + PageDescription, + PageHeader, + PageTitle, +} from '@/components/ui/page-header'; +import { + getBackfills, + getEmbeddingConfigs, + getEmbeddingsSettings, +} from '@/lib/api/embeddings'; +import { + getDeclaredSchemas, + resolveIndexesBySchema, +} from '@/lib/api/embeddings/indexes'; +import { buildConfigListRows } from '@/lib/models/embeddings/index-state'; +import { + isConfigModelInCatalogue, + listConfiguredProviders, +} from '@/lib/models/embeddings/config-catalogue'; +import { settledError, settledValue } from '@/lib/models/embeddings/errors'; + +export default async function EmbeddingConfigsPage() { + const [configsResult, backfillsResult, schemasResult, settingsResult] = + await Promise.allSettled([ + getEmbeddingConfigs(), + getBackfills({ skip: 0, limit: 100 }), + getDeclaredSchemas(), + getEmbeddingsSettings(), + ]); + + const configsError = settledError(configsResult); + if (configsError) { + return ( + + Embeddings overview + + } + /> + ); + } + + const configs = settledValue(configsResult) ?? []; + const runs = settledValue(backfillsResult)?.runs ?? []; + const settings = settledValue(settingsResult)?.config; + const providers = listConfiguredProviders(settings); + const modelBlockedIds = new Set( + settings + ? configs + .filter(config => !isConfigModelInCatalogue(config, providers)) + .map(config => config._id) + : [] + ); + const indexesBySchema = await resolveIndexesBySchema( + configs.map(config => config.schemaName), + settledValue(schemasResult) + ); + const rows = buildConfigListRows( + configs, + indexesBySchema, + runs, + modelBlockedIds + ); + + return ( +
+ +
+ Configs + + Source fields, target field, and matching index. + +
+ + + +
+ +
+ ); +} diff --git a/src/app/(dashboard)/(modules)/embeddings/loading.tsx b/src/app/(dashboard)/(modules)/embeddings/loading.tsx new file mode 100644 index 000000000..bbc014a95 --- /dev/null +++ b/src/app/(dashboard)/(modules)/embeddings/loading.tsx @@ -0,0 +1,19 @@ +import { Skeleton } from '@/components/ui/skeleton'; + +export default function EmbeddingsOverviewLoading() { + return ( +
+ + +
+ + +
+
+ {Array.from({ length: 4 }).map((_, index) => ( + + ))} +
+
+ ); +} diff --git a/src/app/(dashboard)/(modules)/embeddings/page.tsx b/src/app/(dashboard)/(modules)/embeddings/page.tsx new file mode 100644 index 000000000..8663dfd66 --- /dev/null +++ b/src/app/(dashboard)/(modules)/embeddings/page.tsx @@ -0,0 +1,232 @@ +import { + AlertTriangle, + Info, + Plus, + ScanSearch, + Search, + Settings, +} from 'lucide-react'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { ModuleDashboard } from '@/components/dashboard/ModuleDashboard'; +import { MetricCardProps } from '@/components/dashboard/MetricCard'; +import { ModuleStatus } from '@/components/dashboard/ModuleStatusCard'; +import { QuickAction } from '@/components/dashboard/QuickActionsCard'; +import { EmbeddingsReadiness } from '@/components/embeddings/EmbeddingsReadiness'; +import { + getBackfills, + getEmbeddingConfigs, + getEmbeddingsCapabilities, + getEmbeddingsSettings, + getEmbeddingsStatus, +} from '@/lib/api/embeddings'; +import { + getDeclaredSchemas, + resolveIndexesBySchema, +} from '@/lib/api/embeddings/indexes'; +import { + addEmbeddingsQueues, + collectOverviewWarnings, + deriveEmbeddingsReadiness, + emptyEmbeddingsQueue, + settledError, + settledValue, + workersEnabledFromStatus, +} from '@/lib/models/embeddings'; +import { getPrometheusAvailability } from '@/lib/observability/prometheusAvailability'; +import { + getModuleStatus, + getModuleUptime, + getSystemMetrics, +} from '@/lib/prometheus/metrics'; +import { getApiModuleNameFromPath } from '@/lib/utils/module-utils'; + +function metricValue( + value: number | undefined, + unavailable: boolean +): { value: string; status: MetricCardProps['status'] } { + if (unavailable) { + return { value: '—', status: 'unknown' }; + } + return { + value: (value ?? 0).toLocaleString(), + status: 'healthy', + }; +} + +export default async function EmbeddingsDashboard() { + const apiModuleName = getApiModuleNameFromPath('/embeddings') || 'embeddings'; + + const [ + statusResult, + capabilitiesResult, + configsResult, + settingsResult, + backfillsResult, + promAvailabilityResult, + moduleHealthResult, + uptimeResult, + systemMetricsResult, + schemasResult, + ] = await Promise.allSettled([ + getEmbeddingsStatus(), + getEmbeddingsCapabilities(), + getEmbeddingConfigs(), + getEmbeddingsSettings(), + getBackfills({ skip: 0, limit: 1 }), + getPrometheusAvailability(), + getModuleStatus(apiModuleName), + getModuleUptime(apiModuleName), + getSystemMetrics(apiModuleName), + getDeclaredSchemas(), + ]); + + const status = settledValue(statusResult); + const capabilities = + settledValue(capabilitiesResult)?.capabilities ?? status?.capabilities; + const configs = settledValue(configsResult); + const settings = settledValue(settingsResult)?.config; + const backfills = settledValue(backfillsResult); + const promAvailability = settledValue(promAvailabilityResult); + + const indexesBySchema = configs + ? await resolveIndexesBySchema( + configs.map(config => config.schemaName), + settledValue(schemasResult) + ) + : undefined; + + const workersEnabled = workersEnabledFromStatus({ + status, + settingsEnabled: settings?.enabled, + }); + const rows = deriveEmbeddingsReadiness({ + capabilities, + capabilitiesError: settledError(capabilitiesResult), + settings, + settingsError: settledError(settingsResult), + configs, + configsError: settledError(configsResult), + indexesBySchema, + workersEnabled, + }); + + const queueUnavailable = !status; + const queues = status + ? addEmbeddingsQueues(status.generationQueue, status.backfillQueue) + : emptyEmbeddingsQueue(); + + const metricCards: MetricCardProps[] = [ + { + title: 'Queue waiting', + ...metricValue(queues.waiting, queueUnavailable), + description: 'Generation and backfill jobs waiting', + status: queueUnavailable + ? 'unknown' + : queues.waiting > 0 + ? 'warning' + : 'healthy', + }, + { + title: 'Queue active', + ...metricValue(queues.active, queueUnavailable), + description: 'Jobs currently processing', + }, + { + title: 'Queue failed', + ...metricValue(queues.failed, queueUnavailable), + description: 'Failed generation and backfill jobs', + status: queueUnavailable + ? 'unknown' + : queues.failed > 0 + ? 'critical' + : 'healthy', + }, + { + title: 'Configs', + ...metricValue(configs?.length, configs == null), + description: + backfills == null + ? 'Embedding configurations' + : `${backfills.count.toLocaleString()} backfill runs`, + }, + ]; + + const moduleStatus: ModuleStatus = { + name: 'Embeddings', + status: settledValue(moduleHealthResult) ?? 'unknown', + uptime: settledValue(uptimeResult) ?? 'Unknown', + version: '1.0.0', + instances: 1, + description: 'Vector embeddings, indexes, and semantic search', + }; + + const quickActions: QuickAction[] = [ + { + title: 'Configure provider', + description: 'Set endpoint, API key, and models', + icon: , + href: '/embeddings/settings', + }, + { + title: 'New config', + description: 'Create a schema embedding configuration', + icon: , + href: '/embeddings/configs/new', + }, + { + title: 'Test search', + description: 'Run operator semantic search', + icon: , + href: '/embeddings/test', + }, + ]; + + const warnings = collectOverviewWarnings({ + status, + capabilitiesError: settledError(capabilitiesResult), + statusError: settledError(statusResult), + configsError: settledError(configsResult), + settingsError: settledError(settingsResult), + backfillsError: settledError(backfillsResult), + workersEnabled, + capabilitiesSupported: capabilities?.supported, + capabilitiesReason: capabilities?.reason, + }); + + return ( + } + moduleStatus={moduleStatus} + metrics={metricCards} + systemMetrics={settledValue(systemMetricsResult)} + quickActions={quickActions} + prometheusState={promAvailability?.state} + showModuleInformation={false} + prometheusPlacement="after" + leadWithChildren + > +
+ {warnings.length > 0 ? ( +
+ {warnings.map((warning, index) => ( + + {warning.variant === 'destructive' ? ( + + ) : ( + + )} + {warning.title} + {warning.description} + + ))} +
+ ) : null} + +
+
+ ); +} diff --git a/src/app/(dashboard)/(modules)/embeddings/settings/loading.tsx b/src/app/(dashboard)/(modules)/embeddings/settings/loading.tsx new file mode 100644 index 000000000..ea624c102 --- /dev/null +++ b/src/app/(dashboard)/(modules)/embeddings/settings/loading.tsx @@ -0,0 +1,15 @@ +import { Skeleton } from '@/components/ui/skeleton'; + +export default function EmbeddingsSettingsLoading() { + return ( +
+
+ + +
+ + + +
+ ); +} diff --git a/src/app/(dashboard)/(modules)/embeddings/settings/page.tsx b/src/app/(dashboard)/(modules)/embeddings/settings/page.tsx new file mode 100644 index 000000000..f0f3b73f1 --- /dev/null +++ b/src/app/(dashboard)/(modules)/embeddings/settings/page.tsx @@ -0,0 +1,56 @@ +import Link from 'next/link'; +import { EmbeddingsSettings } from '@/components/embeddings/settings/embeddings-settings'; +import { ErrorCard } from '@/components/error/ErrorCard'; +import { Button } from '@/components/ui/button'; +import { + PageDescription, + PageHeader, + PageTitle, +} from '@/components/ui/page-header'; +import { getEmbeddingsSettings } from '@/lib/api/embeddings'; +import { getModules } from '@/lib/api/modules'; +import { isModuleServing } from '@/lib/api/modules/patch-settings-options'; +import { formatEmbeddingsApiError } from '@/lib/models/embeddings/errors'; + +export default async function EmbeddingsSettingsPage() { + const [settingsResult, modulesResult] = await Promise.allSettled([ + getEmbeddingsSettings(), + getModules(), + ]); + + if (settingsResult.status === 'rejected') { + return ( + + Embeddings overview + + } + /> + ); + } + + const serving = + modulesResult.status === 'fulfilled' + ? isModuleServing(modulesResult.value, 'embeddings') + : undefined; + + return ( +
+ +
+ Settings + + Provider, workers, and operational limits. + +
+
+ +
+ ); +} diff --git a/src/app/(dashboard)/(modules)/embeddings/test/loading.tsx b/src/app/(dashboard)/(modules)/embeddings/test/loading.tsx new file mode 100644 index 000000000..bf836a528 --- /dev/null +++ b/src/app/(dashboard)/(modules)/embeddings/test/loading.tsx @@ -0,0 +1,14 @@ +import { Skeleton } from '@/components/ui/skeleton'; + +export default function EmbeddingsTestSearchLoading() { + return ( +
+
+ + +
+ + +
+ ); +} diff --git a/src/app/(dashboard)/(modules)/embeddings/test/page.tsx b/src/app/(dashboard)/(modules)/embeddings/test/page.tsx new file mode 100644 index 000000000..a48838c85 --- /dev/null +++ b/src/app/(dashboard)/(modules)/embeddings/test/page.tsx @@ -0,0 +1,115 @@ +import Link from 'next/link'; +import { TestSearch } from '@/components/embeddings/search/test-search'; +import { ErrorCard } from '@/components/error/ErrorCard'; +import { Button } from '@/components/ui/button'; +import { + PageDescription, + PageHeader, + PageTitle, +} from '@/components/ui/page-header'; +import { + getEmbeddingConfigs, + getEmbeddingsCapabilities, + getEmbeddingsSettings, + getEmbeddingsStatus, +} from '@/lib/api/embeddings'; +import { + getDeclaredSchemas, + resolveIndexesBySchema, +} from '@/lib/api/embeddings/indexes'; +import { settledError, settledValue } from '@/lib/models/embeddings/errors'; +import { workersEnabledFromStatus } from '@/lib/models/embeddings/overview-view'; +import { readSearchParam } from '@/lib/models/embeddings/backfill-view'; +import { buildSearchPageModel } from '@/lib/models/embeddings/search-view'; +import { filterByEligibleSchemas } from '@/lib/models/embeddings/source-fields'; + +export default async function EmbeddingsTestSearchPage(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + const [ + configsResult, + statusResult, + capabilitiesResult, + settingsResult, + schemasResult, + ] = await Promise.allSettled([ + getEmbeddingConfigs(), + getEmbeddingsStatus(), + getEmbeddingsCapabilities(), + getEmbeddingsSettings(), + getDeclaredSchemas(), + ]); + + const configsError = settledError(configsResult); + if (configsError) { + return ( + + Embeddings overview + + } + /> + ); + } + + const configs = settledValue(configsResult) ?? []; + const status = settledValue(statusResult); + const capabilities = + settledValue(capabilitiesResult)?.capabilities ?? status?.capabilities; + const settings = settledValue(settingsResult)?.config; + const declared = settledValue(schemasResult); + const searchableConfigs = filterByEligibleSchemas( + configs, + declared?.schemas, + declared?.systemSchemaNames + ); + const indexesBySchema = await resolveIndexesBySchema( + searchableConfigs.map(config => config.schemaName), + declared + ); + const workersEnabled = workersEnabledFromStatus({ + status, + settingsEnabled: settings?.enabled, + }); + const model = buildSearchPageModel({ + configs: searchableConfigs, + indexesBySchema, + capabilities, + capabilitiesError: settledError(capabilitiesResult), + settings, + settingsError: settledError(settingsResult), + workersEnabled, + configId: + readSearchParam(searchParams.configId) ?? + readSearchParam(searchParams.config), + schemaName: + readSearchParam(searchParams.schema) ?? + readSearchParam(searchParams.schemaName), + }); + + return ( +
+ +
+ Test Search + + Run semantic search against a ready config. + +
+
+ +
+ ); +} diff --git a/src/app/(dashboard)/template.tsx b/src/app/(dashboard)/template.tsx index 206a304d3..700b1c234 100644 --- a/src/app/(dashboard)/template.tsx +++ b/src/app/(dashboard)/template.tsx @@ -2,7 +2,8 @@ import { usePathname } from 'next/navigation'; import Link from 'next/link'; import Image from 'next/image'; -import React, { useEffect, useState } from 'react'; +import React, { Fragment, useEffect, useState } from 'react'; +import { formatBreadcrumbSegment } from '@/lib/utils/breadcrumbs'; import { getRouterSettings } from '@/lib/api/router'; import { getAdminSettings } from '@/lib/api/settings'; import { ScalarIcon, SocketIcon } from '@/icons'; @@ -36,6 +37,7 @@ const MODULE_NAMES: { [key: string]: string } = { authentication: 'Authentication', authorization: 'Authorization', database: 'Database', + embeddings: 'Embeddings', storage: 'Storage', chat: 'Chat', forms: 'Forms', @@ -48,22 +50,55 @@ const MODULE_NAMES: { [key: string]: string } = { payments: 'Payments', }; -const SEGMENT_LABELS: Record = { - templates: 'Templates', - logs: 'Logs & Devices', - settings: 'Settings', - test: 'Test Send', -}; +function ModuleSubpathCrumbs({ + pathSegments, + moduleSlug, +}: { + pathSegments: string[]; + moduleSlug?: string; +}) { + if (moduleSlug === 'embeddings') { + return pathSegments.slice(1).map((segment, index) => { + const segmentIndex = index + 1; + const href = `/${pathSegments.slice(0, segmentIndex + 1).join('/')}`; + const isLast = segmentIndex === pathSegments.length - 1; + const label = formatBreadcrumbSegment( + segment, + moduleSlug, + pathSegments[segmentIndex - 1] + ); + return ( + + + + {isLast ? ( + {label} + ) : ( + + {label} + + )} + + + ); + }); + } -function formatBreadcrumbSegment(segment: string): string { return ( - SEGMENT_LABELS[segment] ?? - segment - .split('-') - .map(w => w.charAt(0).toUpperCase() + w.slice(1)) - .join(' ') + <> + + + + {formatBreadcrumbSegment( + pathSegments[pathSegments.length - 1], + moduleSlug + )} + + + ); } + export default function ModuleHeader({ children, }: { @@ -208,7 +243,8 @@ export default function ModuleHeader({ {formatBreadcrumbSegment( - pathSegments[pathSegments.length - 1] + pathSegments[pathSegments.length - 1], + whichModule )} @@ -225,7 +261,10 @@ export default function ModuleHeader({ - {formatBreadcrumbSegment(pathSegments[1])} + {formatBreadcrumbSegment( + pathSegments[1], + whichModule + )} @@ -255,16 +294,10 @@ export default function ModuleHeader({ )} {pathSegments.length > 1 && ( - <> - - - - {formatBreadcrumbSegment( - pathSegments[pathSegments.length - 1] - )} - - - + )} )} diff --git a/src/components/dashboard/ActiveModuleGrid.tsx b/src/components/dashboard/ActiveModuleGrid.tsx index d27f4a631..b9a902832 100644 --- a/src/components/dashboard/ActiveModuleGrid.tsx +++ b/src/components/dashboard/ActiveModuleGrid.tsx @@ -5,6 +5,7 @@ import type { ResolvedModuleState } from '@/lib/prometheus/metrics'; import { Users, Database, + ScanSearch, HardDrive, MessageSquare, CreditCard, @@ -21,6 +22,7 @@ const MODULE_ICONS: Record = { authentication: Users, authorization: KeyRound, database: Database, + embeddings: ScanSearch, email: Mail, storage: HardDrive, functions: FunctionSquare, diff --git a/src/components/dashboard/ModuleDashboard.tsx b/src/components/dashboard/ModuleDashboard.tsx index 259b04314..d49426fef 100644 --- a/src/components/dashboard/ModuleDashboard.tsx +++ b/src/components/dashboard/ModuleDashboard.tsx @@ -36,6 +36,9 @@ export interface ModuleDashboardProps { className?: string; prometheusState?: ObservabilityServiceState; sharedRuntime?: SharedRuntimeInfo; + showModuleInformation?: boolean; + prometheusPlacement?: 'before' | 'after'; + leadWithChildren?: boolean; } export const ModuleDashboard: React.FC = ({ @@ -49,7 +52,22 @@ export const ModuleDashboard: React.FC = ({ className, prometheusState, sharedRuntime, + showModuleInformation = true, + prometheusPlacement = 'before', + leadWithChildren = false, }) => { + const prometheusAlert = + prometheusState && prometheusState !== 'ready' ? ( + + + + {prometheusState === 'not_configured' + ? 'Metrics are disabled for this environment. Set PROMETHEUS_URL to enable.' + : 'Cannot reach Prometheus at the configured URL.'} + + + ) : null; + return ( = ({ initial="initial" animate="animate" > - {prometheusState && prometheusState !== 'ready' && ( - - - - {prometheusState === 'not_configured' - ? 'Metrics are disabled for this environment. Set PROMETHEUS_URL to enable.' - : 'Cannot reach Prometheus at the configured URL.'} - - - )} + {prometheusPlacement === 'before' ? prometheusAlert : null}
@@ -81,59 +90,71 @@ export const ModuleDashboard: React.FC = ({ /> - + {leadWithChildren && children ? ( + {children} + ) : null} + + - - - - Module Information - - - -
-
- Module: - {moduleName} -
-
- Status: - - {moduleStatus.status} - -
- {moduleStatus.version && ( + {showModuleInformation ? ( + + + + Module Information + + + +
- Version: - {moduleStatus.version} + Module: + {moduleName}
- )} - {moduleStatus.instances !== undefined && (
- Instances: - - {moduleStatus.instances} + Status: + + {moduleStatus.status}
- )} - {sharedRuntime && ( - <> + {moduleStatus.version && (
- - Runtime module: - - - {sharedRuntime.moduleName} + Version: + {moduleStatus.version} +
+ )} + {moduleStatus.instances !== undefined && ( +
+ Instances: + + {moduleStatus.instances}
-

- {sharedRuntime.description} -

- - )} -
-
-
+ )} + {sharedRuntime && ( + <> +
+ + Runtime module: + + + {sharedRuntime.moduleName} + +
+

+ {sharedRuntime.description} +

+ + )} +
+
+
+ ) : null}
@@ -154,7 +175,11 @@ export const ModuleDashboard: React.FC = ({ )} - {children && {children}} + {!leadWithChildren && children ? ( + {children} + ) : null} + + {prometheusPlacement === 'after' ? prometheusAlert : null}
); }; diff --git a/src/components/embeddings/EmbeddingsReadiness.tsx b/src/components/embeddings/EmbeddingsReadiness.tsx new file mode 100644 index 000000000..475a0eacd --- /dev/null +++ b/src/components/embeddings/EmbeddingsReadiness.tsx @@ -0,0 +1,110 @@ +import Link from 'next/link'; +import { + AlertTriangle, + CheckCircle2, + CircleHelp, + Clock, + type LucideIcon, +} from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { cn } from '@/lib/utils'; +import { + getNextReadinessAction, + type ReadinessRow, + type ReadinessState, +} from '@/lib/models/embeddings/readiness'; + +const STATE_LABEL: Record = { + ready: 'Ready', + waiting: 'Waiting', + blocked: 'Blocked', + unknown: 'Unknown', +}; + +const STATE_ICON: Record = { + ready: CheckCircle2, + waiting: Clock, + blocked: AlertTriangle, + unknown: CircleHelp, +}; + +const STATE_ICON_CLASS: Record = { + ready: 'text-status-healthy', + waiting: 'text-status-warning', + blocked: 'text-status-critical', + unknown: 'text-status-unknown', +}; + +type EmbeddingsReadinessProps = { + rows: ReadinessRow[]; + className?: string; +}; + +export function EmbeddingsReadiness({ + rows, + className, +}: EmbeddingsReadinessProps) { + const next = getNextReadinessAction(rows); + + return ( + + + Readiness + {next ? ( +

+ Next: {next.detail} +

+ ) : ( +

+ Capabilities, provider, index, config, and workers are ready. +

+ )} +
+ +
    + {rows.map((row, index) => { + const Icon = STATE_ICON[row.state]; + return ( +
  • + +
    +

    + {row.label} +

    +

    + {row.detail} +

    +
    + + {STATE_LABEL[row.state]} + + {row.href && row.actionLabel ? ( + + {row.actionLabel} + + ) : null} +
  • + ); + })} +
+
+
+ ); +} diff --git a/src/components/embeddings/backfills/backfill-actions.tsx b/src/components/embeddings/backfills/backfill-actions.tsx new file mode 100644 index 000000000..7c02ebd72 --- /dev/null +++ b/src/components/embeddings/backfills/backfill-actions.tsx @@ -0,0 +1,110 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { CancelBackfillDialog } from '@/components/embeddings/backfills/cancel-backfill-dialog'; +import { ResumeBackfillDialog } from '@/components/embeddings/backfills/resume-backfill-dialog'; +import { Button } from '@/components/ui/button'; +import { cancelBackfill, resumeBackfill } from '@/lib/api/embeddings'; +import { toast } from '@/lib/hooks/use-toast'; +import { + BackfillRun, + canCancelBackfill, + isResumeEligible, +} from '@/lib/models/embeddings/backfill'; +import { formatEmbeddingsApiError } from '@/lib/models/embeddings/errors'; + +type BackfillActionsProps = { + run: BackfillRun; + compact?: boolean; +}; + +export function BackfillActions({ + run, + compact = false, +}: BackfillActionsProps) { + const router = useRouter(); + const [cancelOpen, setCancelOpen] = useState(false); + const [resumeOpen, setResumeOpen] = useState(false); + const [pending, setPending] = useState(false); + const canCancel = canCancelBackfill(run.state); + const canResume = isResumeEligible(run.state); + + if (!canCancel && !canResume) return null; + + const onCancel = async () => { + setPending(true); + try { + await cancelBackfill(run._id); + toast({ title: 'Backfill canceled' }); + setCancelOpen(false); + router.refresh(); + } catch (error) { + toast({ + title: 'Could not cancel backfill', + description: formatEmbeddingsApiError(error), + variant: 'destructive', + }); + } finally { + setPending(false); + } + }; + + const onResume = async () => { + setPending(true); + try { + await resumeBackfill(run._id); + toast({ title: 'Backfill resumed' }); + setResumeOpen(false); + router.refresh(); + } catch (error) { + toast({ + title: 'Could not resume backfill', + description: formatEmbeddingsApiError(error), + variant: 'destructive', + }); + } finally { + setPending(false); + } + }; + + return ( + <> + {canCancel ? ( + + ) : null} + {canResume ? ( + + ) : null} + void onCancel()} + /> + void onResume()} + /> + + ); +} diff --git a/src/components/embeddings/backfills/backfill-counters.tsx b/src/components/embeddings/backfills/backfill-counters.tsx new file mode 100644 index 000000000..0a2881316 --- /dev/null +++ b/src/components/embeddings/backfills/backfill-counters.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { BackfillRun } from '@/lib/models/embeddings/backfill'; +import { backfillProgress } from '@/lib/models/embeddings/backfill-view'; + +type BackfillCountersProps = { + run: BackfillRun; +}; + +function Count({ label, value }: { label: string; value: number }) { + return ( +
+

{label}

+

+ {value.toLocaleString()} +

+
+ ); +} + +export function BackfillCounters({ run }: BackfillCountersProps) { + const progress = backfillProgress(run); + return ( +
+
+ + + + +
+
+ {progress.percent != null ? ( +
+
+
+ ) : null} +

+ {progress.caption} +

+
+
+ ); +} diff --git a/src/components/embeddings/backfills/backfill-detail.tsx b/src/components/embeddings/backfills/backfill-detail.tsx new file mode 100644 index 000000000..d11712d5b --- /dev/null +++ b/src/components/embeddings/backfills/backfill-detail.tsx @@ -0,0 +1,155 @@ +'use client'; + +import Link from 'next/link'; +import { AlertTriangle } from 'lucide-react'; +import { BackfillActions } from '@/components/embeddings/backfills/backfill-actions'; +import { BackfillCounters } from '@/components/embeddings/backfills/backfill-counters'; +import { BackfillStateBadge } from '@/components/embeddings/backfills/backfill-state-badge'; +import { useBackfillPolling } from '@/components/embeddings/backfills/use-backfill-polling'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + PageActions, + PageDescription, + PageHeader, + PageTitle, +} from '@/components/ui/page-header'; +import { + BackfillRun, + isActiveBackfillState, +} from '@/lib/models/embeddings/backfill'; +import { + backfillTimeline, + formatBackfillTimestamp, + sanitizeBackfillErrorDisplay, +} from '@/lib/models/embeddings/backfill-view'; +import { cn } from '@/lib/utils'; + +type BackfillDetailProps = { + run: BackfillRun; +}; + +function Fact({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +export function BackfillDetail({ run }: BackfillDetailProps) { + useBackfillPolling(isActiveBackfillState(run.state)); + const timeline = backfillTimeline(run); + const error = sanitizeBackfillErrorDisplay(run.error); + const filterJson = + run.filter && Object.keys(run.filter).length > 0 + ? JSON.stringify(run.filter, null, 2) + : undefined; + + return ( +
+ +
+ {run.schemaName} + + Backfill run {run._id}. Queued progress does not mean the collection + is fully scanned. + +
+ + + +
+ + + State + + + +
    + {timeline.map(item => ( +
  1. +

    + {item.label} +

    +

    + {formatBackfillTimestamp(item.at)} +

    +
  2. + ))} +
+
+
+ + + Counters + + + + + + + + Run details + + +
+ + + + + + + + +
+ {run.configId ? ( +

+ + Open config + +

+ ) : null} + {filterJson ? ( +
+              {filterJson}
+            
+ ) : null} +
+
+ {error ? ( + + + Run error + {error} + + ) : null} +
+ ); +} diff --git a/src/components/embeddings/backfills/backfill-filters.tsx b/src/components/embeddings/backfills/backfill-filters.tsx new file mode 100644 index 000000000..6082f9be9 --- /dev/null +++ b/src/components/embeddings/backfills/backfill-filters.tsx @@ -0,0 +1,138 @@ +'use client'; + +import { usePathname, useRouter } from 'next/navigation'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { BACKFILL_RUN_STATES } from '@/lib/models/embeddings/backfill'; +import { EmbeddingConfigOption } from '@/lib/models/embeddings/config'; +import { + backfillStateLabel, + BackfillListUrlState, + DEFAULT_BACKFILL_LIST_LIMIT, +} from '@/lib/models/embeddings/backfill-view'; + +const ALL = 'all'; + +type BackfillFiltersProps = { + query: BackfillListUrlState; + schemas: string[]; + configs: EmbeddingConfigOption[]; +}; + +function FilterSelect({ + id, + label, + value, + placeholder, + onChange, + options, +}: { + id: string; + label: string; + value: string; + placeholder: string; + onChange: (value: string) => void; + options: { value: string; label: string }[]; +}) { + return ( +
+ + +
+ ); +} + +export function BackfillFilters({ + query, + schemas, + configs, +}: BackfillFiltersProps) { + const router = useRouter(); + const pathname = usePathname(); + const schemaConfigs = query.schema + ? configs.filter(config => config.schemaName === query.schema) + : configs; + const configOptions = schemaConfigs.map(config => ({ + value: config._id, + label: `${config.schemaName} · ${config.targetField}`, + })); + if ( + query.config && + !configOptions.some(option => option.value === query.config) + ) { + configOptions.unshift({ value: query.config, label: query.config }); + } + + const setFilter = (key: 'schema' | 'config' | 'state', value: string) => { + const params = new URLSearchParams(); + const nextSchema = key === 'schema' ? value : (query.schema ?? ALL); + const nextConfig = key === 'config' ? value : (query.config ?? ALL); + const nextState = key === 'state' ? value : (query.state ?? ALL); + if (nextSchema !== ALL) params.set('schema', nextSchema); + let configValue = nextConfig; + if (key === 'schema') { + const selected = query.config; + const match = configs.find(config => config._id === selected); + if (match && value !== ALL && match.schemaName !== value) { + configValue = ALL; + } + } + if (configValue !== ALL) params.set('config', configValue); + if (nextState !== ALL) params.set('state', nextState); + if (query.limit !== DEFAULT_BACKFILL_LIST_LIMIT) { + params.set('limit', String(query.limit)); + } + const next = params.toString(); + router.push(next ? `${pathname}?${next}` : pathname); + }; + + return ( +
+ setFilter('schema', value)} + options={schemas.map(schema => ({ value: schema, label: schema }))} + /> + setFilter('config', value)} + options={configOptions} + /> + setFilter('state', value)} + options={BACKFILL_RUN_STATES.map(state => ({ + value: state, + label: backfillStateLabel(state), + }))} + /> +
+ ); +} diff --git a/src/components/embeddings/backfills/backfill-state-badge.tsx b/src/components/embeddings/backfills/backfill-state-badge.tsx new file mode 100644 index 000000000..ccb2eeda0 --- /dev/null +++ b/src/components/embeddings/backfills/backfill-state-badge.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { + AlertTriangle, + Ban, + CheckCircle2, + Clock, + Play, + type LucideIcon, +} from 'lucide-react'; +import { BackfillRunState } from '@/lib/models/embeddings/backfill'; +import { + backfillStateClass, + backfillStateLabel, +} from '@/lib/models/embeddings/backfill-view'; +import { cn } from '@/lib/utils'; + +const STATE_ICON: Record = { + queued: Clock, + running: Play, + completed: CheckCircle2, + failed: AlertTriangle, + canceled: Ban, +}; + +type BackfillStateBadgeProps = { + state: BackfillRunState; + className?: string; +}; + +export function BackfillStateBadge({ + state, + className, +}: BackfillStateBadgeProps) { + const Icon = STATE_ICON[state]; + const color = backfillStateClass(state); + return ( + + + {backfillStateLabel(state)} + + ); +} diff --git a/src/components/embeddings/backfills/backfills-table.tsx b/src/components/embeddings/backfills/backfills-table.tsx new file mode 100644 index 000000000..0875bddcc --- /dev/null +++ b/src/components/embeddings/backfills/backfills-table.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { Suspense } from 'react'; +import Link from 'next/link'; +import { BackfillFilters } from '@/components/embeddings/backfills/backfill-filters'; +import { BACKFILL_COLUMNS } from '@/components/embeddings/backfills/columns'; +import { useBackfillPolling } from '@/components/embeddings/backfills/use-backfill-polling'; +import { DataTable } from '@/components/ui/data-table'; +import { Button } from '@/components/ui/button'; +import { BackfillRun } from '@/lib/models/embeddings/backfill'; +import { EmbeddingConfigOption } from '@/lib/models/embeddings/config'; +import { + BackfillListUrlState, + hasActiveBackfillRuns, +} from '@/lib/models/embeddings/backfill-view'; + +type BackfillsTableProps = { + runs: BackfillRun[]; + count: number; + query: BackfillListUrlState; + configs: EmbeddingConfigOption[]; + schemas: string[]; + hasConfigs: boolean; +}; + +export function BackfillsTable({ + runs, + count, + query, + configs, + schemas, + hasConfigs, +}: BackfillsTableProps) { + useBackfillPolling(hasActiveBackfillRuns(runs)); + + return ( +
+ + + } + > + +
+

No backfill runs

+

+ {hasConfigs + ? 'Start a run from a ready config. Progress is of queued documents, not the full collection.' + : 'Create a config first, then start a backfill from this page.'} +

+ {!hasConfigs ? ( + + ) : null} +
+
+
+
+ ); +} diff --git a/src/components/embeddings/backfills/cancel-backfill-dialog.tsx b/src/components/embeddings/backfills/cancel-backfill-dialog.tsx new file mode 100644 index 000000000..6100e4ea7 --- /dev/null +++ b/src/components/embeddings/backfills/cancel-backfill-dialog.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; + +type CancelBackfillDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + pending?: boolean; +}; + +export function CancelBackfillDialog({ + open, + onOpenChange, + onConfirm, + pending = false, +}: CancelBackfillDialogProps) { + return ( + { + if (pending && !next) return; + onOpenChange(next); + }} + > + + + Cancel this run? + + This stops further scheduling. In-flight writes may still finish. + + + + Keep running + { + event.preventDefault(); + onConfirm(); + }} + > + {pending ? 'Canceling…' : 'Cancel run'} + + + + + ); +} diff --git a/src/components/embeddings/backfills/columns.tsx b/src/components/embeddings/backfills/columns.tsx new file mode 100644 index 000000000..19c2e3a94 --- /dev/null +++ b/src/components/embeddings/backfills/columns.tsx @@ -0,0 +1,90 @@ +'use client'; + +import { ColumnDef } from '@tanstack/react-table'; +import Link from 'next/link'; +import { BackfillActions } from '@/components/embeddings/backfills/backfill-actions'; +import { BackfillStateBadge } from '@/components/embeddings/backfills/backfill-state-badge'; +import { BackfillRun } from '@/lib/models/embeddings/backfill'; +import { + backfillProgress, + formatBackfillTimestamp, +} from '@/lib/models/embeddings/backfill-view'; + +export const BACKFILL_COLUMNS: ColumnDef[] = [ + { + id: 'schema', + header: 'Schema', + cell: ({ row }) => ( + + {row.original.schemaName} + + ), + }, + { + id: 'actions', + header: 'Actions', + cell: ({ row }) => ( +
+ +
+ ), + }, + { + id: 'state', + header: 'State', + cell: ({ row }) => , + }, + { + id: 'progress', + header: 'Queued progress', + cell: ({ row }) => { + const progress = backfillProgress(row.original); + return ( +
+

+ {progress.queued > 0 + ? `${progress.done.toLocaleString()} / ${progress.queued.toLocaleString()}` + : progress.scanning + ? 'Scanning' + : '—'} +

+

+ {progress.scanned.toLocaleString()} scanned + {progress.failed > 0 + ? ` · ${progress.failed.toLocaleString()} failed` + : ''} +

+
+ ); + }, + }, + { + id: 'config', + header: 'Config', + cell: ({ row }) => + row.original.configId ? ( + + {row.original.configId} + + ) : ( + All enabled + ), + }, + { + id: 'updated', + header: 'Updated', + cell: ({ row }) => ( + + {formatBackfillTimestamp( + row.original.updatedAt ?? row.original.createdAt + )} + + ), + }, +]; diff --git a/src/components/embeddings/backfills/resume-backfill-dialog.tsx b/src/components/embeddings/backfills/resume-backfill-dialog.tsx new file mode 100644 index 000000000..37cbc006b --- /dev/null +++ b/src/components/embeddings/backfills/resume-backfill-dialog.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; + +type ResumeBackfillDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + pending?: boolean; +}; + +export function ResumeBackfillDialog({ + open, + onOpenChange, + onConfirm, + pending = false, +}: ResumeBackfillDialogProps) { + return ( + { + if (pending && !next) return; + onOpenChange(next); + }} + > + + + Resume this run? + + Resume queues remaining documents and uses provider quota. + + + + Keep paused + { + event.preventDefault(); + onConfirm(); + }} + > + {pending ? 'Resuming…' : 'Resume run'} + + + + + ); +} diff --git a/src/components/embeddings/backfills/start-backfill-dialog.tsx b/src/components/embeddings/backfills/start-backfill-dialog.tsx new file mode 100644 index 000000000..89f6bd025 --- /dev/null +++ b/src/components/embeddings/backfills/start-backfill-dialog.tsx @@ -0,0 +1,346 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { ChevronDown } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; +import { Textarea } from '@/components/ui/textarea'; +import { startBackfill } from '@/lib/api/embeddings'; +import { toast } from '@/lib/hooks/use-toast'; +import { EmbeddingConfigOption } from '@/lib/models/embeddings/config'; +import { formatEmbeddingsApiError } from '@/lib/models/embeddings/errors'; +import { + defaultBackfillBatchSize, + maxAllowedBatchSize, + parseBatchSizeInput, + parseOperatorFilterJson, + startBackfillConfigs, + startBackfillSchemas, +} from '@/lib/models/embeddings/backfill-view'; + +const ALL_CONFIGS = 'all'; + +type StartBackfillDialogProps = { + configs: EmbeddingConfigOption[]; + maxBatchSize?: number; + defaultSchema?: string; + defaultConfig?: string; +}; + +function startBackfillFormDefaults( + configs: EmbeddingConfigOption[], + maxBatchSize?: number, + defaultSchema?: string, + defaultConfig?: string +) { + const enabledConfigs = startBackfillConfigs(configs); + const schemaOptions = startBackfillSchemas(configs); + const matched = enabledConfigs.find(config => config._id === defaultConfig); + return { + enabledConfigs, + schemaOptions, + schemaName: + defaultSchema && schemaOptions.includes(defaultSchema) + ? defaultSchema + : (matched?.schemaName ?? ''), + configId: matched?._id ?? ALL_CONFIGS, + batchSize: String(defaultBackfillBatchSize(maxBatchSize)), + }; +} + +export function StartBackfillDialog({ + configs, + maxBatchSize, + defaultSchema, + defaultConfig, +}: StartBackfillDialogProps) { + const router = useRouter(); + const allowedMax = maxAllowedBatchSize(maxBatchSize); + const defaults = useMemo( + () => + startBackfillFormDefaults( + configs, + maxBatchSize, + defaultSchema, + defaultConfig + ), + [configs, maxBatchSize, defaultSchema, defaultConfig] + ); + const { enabledConfigs, schemaOptions } = defaults; + const [open, setOpen] = useState(false); + const [schemaName, setSchemaName] = useState(defaults.schemaName); + const [configId, setConfigId] = useState(defaults.configId); + const [onlyMissing, setOnlyMissing] = useState(true); + const [batchSize, setBatchSize] = useState(defaults.batchSize); + const [filterText, setFilterText] = useState(''); + const [filterError, setFilterError] = useState(); + const [batchError, setBatchError] = useState(); + const [pending, setPending] = useState(false); + + const schemaConfigs = useMemo( + () => startBackfillConfigs(configs, schemaName || undefined), + [configs, schemaName] + ); + + const reset = () => { + const next = startBackfillFormDefaults( + configs, + maxBatchSize, + defaultSchema, + defaultConfig + ); + setSchemaName(next.schemaName); + setConfigId(next.configId); + setOnlyMissing(true); + setBatchSize(next.batchSize); + setFilterText(''); + setFilterError(undefined); + setBatchError(undefined); + }; + + const submit = async () => { + const batch = parseBatchSizeInput(batchSize, maxBatchSize); + const filter = parseOperatorFilterJson(filterText); + setBatchError(batch.ok ? undefined : batch.error); + setFilterError(filter.ok ? undefined : filter.error); + if (!schemaName) return; + if (!batch.ok || !filter.ok) return; + setPending(true); + try { + const result = await startBackfill({ + schemaName, + batchSize: batch.batchSize, + onlyMissing, + configId: configId === ALL_CONFIGS ? undefined : configId, + filter: filter.filter, + }); + toast({ + title: 'Backfill queued', + description: + result.warnings.length > 0 + ? result.warnings.join(' ') + : `${result.queued.toLocaleString()} run${result.queued === 1 ? '' : 's'} queued`, + }); + setOpen(false); + reset(); + router.refresh(); + } catch (error) { + toast({ + title: 'Could not start backfill', + description: formatEmbeddingsApiError(error), + variant: 'destructive', + }); + } finally { + setPending(false); + } + }; + + const disabled = enabledConfigs.length === 0; + + return ( + { + if (pending && !next) return; + setOpen(next); + if (next) reset(); + }} + > + + + + +
{ + event.preventDefault(); + void submit(); + }} + className="grid gap-4" + > + + Start backfill + + Queue embedding generation for documents in a schema. Progress is + of queued documents, not the full collection. + + +
+ + +
+
+ + +
+
+ + { + setBatchSize(event.target.value); + setBatchError(undefined); + }} + /> +

+ {`Default 100. Maximum ${allowedMax.toLocaleString()}.`} +

+ {batchError ? ( + + ) : null} +
+ + + + Advanced filter + + + + +