diff --git a/app/performance/PerformanceEstimate.prefill.test.tsx b/app/performance/PerformanceEstimate.prefill.test.tsx new file mode 100644 index 0000000..d5418fb --- /dev/null +++ b/app/performance/PerformanceEstimate.prefill.test.tsx @@ -0,0 +1,129 @@ +// @vitest-environment happy-dom +import * as React from 'react'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/contexts/SettingsContext', () => ({ + useSettings: () => ({ + hydrated: true, + hfToken: '', + defaultModel: 'settings/default-model', + inferenceBackend: 'vllm', + backendVersion: 'latest', + }), +})); + +vi.mock('@/lib/app-config', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getAppConfig: () => ({ + ...actual.getAppConfig(), + defaultSystem: 'retired_gpu', + }), + }; +}); + +vi.mock('@/lib/hooks/useAicCatalog', () => ({ + useAicCatalog: () => ({ + gpuOptions: [ + { systemId: 'h200_sxm', displayName: 'NVIDIA H200', vramGb: 141 }, + { systemId: 'h100_sxm', displayName: 'NVIDIA H100', vramGb: 80 }, + ], + modelOptions: ['Qwen/Qwen2.5-7B-Instruct', 'settings/default-model'], + modelSpecs: new Map(), + isLoading: false, + }), +})); + +vi.mock('@/components/ui/ModelInput', () => ({ + ModelInput: ({ id, model, onChange }: { id: string; model: string; onChange: (value: string) => void }) => ( + + ), +})); + +vi.mock('@/components/ui/GpuSystemInput', () => ({ + GpuSystemInput: ({ id, value, onChange, gpuOptions }: { + id: string; + value: string; + onChange: (value: string) => void; + gpuOptions: Array<{ systemId: string; displayName: string }>; + }) => ( + + ), +})); + +vi.mock('./quickEstimateHelpers', () => ({ + Term: ({ children }: React.PropsWithChildren) => <>{children}, + FlipTile: ({ children }: React.PropsWithChildren) => <>{children}, + Sparkline: () => null, + useCountUp: (value: number) => value, +})); +vi.mock('@/components/ProductTour', () => ({ ProductTour: () => null })); +vi.mock('./SaveEstimateModal', () => ({ SaveEstimateModal: () => null })); +vi.mock('@/components/GpuChipLoader/GpuChipLoader', () => ({ GpuChipLoader: () => null })); +vi.mock('@/components/ui/InfoStrip', () => ({ + InfoStrip: ({ children }: React.PropsWithChildren) =>
{children}
, + InfoStripAction: ({ children }: React.PropsWithChildren) => {children}, +})); + +import PerformanceEstimate from './PerformanceEstimate'; + +let container: HTMLDivElement; +let root: ReturnType; + +beforeEach(() => { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }) + .IS_REACT_ACT_ENVIRONMENT = true; + vi.stubGlobal('localStorage', { + getItem: vi.fn(() => 'seen'), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + }); + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: true, + json: async () => ({ data: [] }), + }))); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + window.history.replaceState({}, '', '/performance'); + vi.unstubAllGlobals(); +}); + +async function mountAt(search: string) { + window.history.replaceState({}, '', `/performance${search}`); + await act(async () => { + root.render(); + await Promise.resolve(); + }); + return { + model: container.querySelector('#qe-model'), + gpu: container.querySelector('#qe-gpu'), + }; +} + +describe('performance page widget handoff', () => { + it('hydrates both destination controls without settings overwriting the model', async () => { + const { model, gpu } = await mountAt( + '?model=Qwen%2FQwen2.5-7B-Instruct&system=h100_sxm', + ); + expect(model?.value).toBe('Qwen/Qwen2.5-7B-Instruct'); + expect(gpu?.value).toBe('h100_sxm'); + }); + + it('ignores invalid handoff values and uses safe page defaults', async () => { + const { model, gpu } = await mountAt('?model=javascript%3Aalert(1)&system=h200%20sxm'); + expect(model?.value).toBe('settings/default-model'); + expect(gpu?.value).toBe('h200_sxm'); + }); +}); diff --git a/app/performance/PerformanceEstimate.tsx b/app/performance/PerformanceEstimate.tsx index 3a2329a..3469adf 100644 --- a/app/performance/PerformanceEstimate.tsx +++ b/app/performance/PerformanceEstimate.tsx @@ -80,21 +80,31 @@ export default function QuickEstimate() { const [model, setModel] = React.useState(''); const [gpu, setGpu] = React.useState(() => getAppConfig().defaultSystem); + const [prefillChecked, setPrefillChecked] = React.useState(false); + const modelWasPrefilled = React.useRef(false); + + React.useEffect(() => { + const prefill = parsePerformancePrefill(globalThis.location?.search || ''); + modelWasPrefilled.current = Boolean(prefill.model); + if (prefill.model) setModel(prefill.model); + if (prefill.system) setGpu(prefill.system); + setPrefillChecked(true); + }, []); // Set model from settings after context has loaded from localStorage const modelFromSettings = React.useRef(false); React.useEffect(() => { - if (!hydrated || modelFromSettings.current) return; + if (!hydrated || !prefillChecked || modelFromSettings.current) return; modelFromSettings.current = true; - setModel(settingsDefaultModel); - }, [hydrated, settingsDefaultModel]); + if (!modelWasPrefilled.current) setModel(settingsDefaultModel); + }, [hydrated, prefillChecked, settingsDefaultModel]); // If defaultSystem not in catalog, fall back to first available React.useEffect(() => { - if (aicGpus.length > 0 && !aicGpus.find(g => g.systemId === gpu)) { + if (prefillChecked && aicGpus.length > 0 && !aicGpus.find(g => g.systemId === gpu)) { setGpu(aicGpus[0].systemId); } - }, [aicGpus, gpu]); + }, [aicGpus, gpu, prefillChecked]); const [fav, setFav] = React.useState(false); const [expanded, setExpanded] = React.useState([]); diff --git a/app/performance/performance-prefill.test.ts b/app/performance/performance-prefill.test.ts new file mode 100644 index 0000000..8073be9 --- /dev/null +++ b/app/performance/performance-prefill.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { parsePerformancePrefill } from './performance-prefill'; + +describe('performance page prefill', () => { + it('accepts the widget model and system handoff', () => { + expect(parsePerformancePrefill( + '?model=Qwen%2FQwen2.5-7B-Instruct&system=h200_sxm', + )).toEqual({ + model: 'Qwen/Qwen2.5-7B-Instruct', + system: 'h200_sxm', + }); + }); + + it('ignores malformed or oversized values', () => { + expect(parsePerformancePrefill('?model=javascript%3Aalert(1)&system=h200%20sxm')) + .toEqual({ model: null, system: null }); + expect(parsePerformancePrefill(`?model=org/${'a'.repeat(220)}&system=h200_sxm`)) + .toEqual({ model: null, system: 'h200_sxm' }); + }); +}); diff --git a/app/performance/performance-prefill.ts b/app/performance/performance-prefill.ts new file mode 100644 index 0000000..d888da4 --- /dev/null +++ b/app/performance/performance-prefill.ts @@ -0,0 +1,23 @@ +const MODEL_PATH = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/; +const SYSTEM_ID = /^[A-Za-z0-9._-]+$/; + +function validParam(value: string | null, pattern: RegExp, maxLength: number) { + const normalized = value?.trim() ?? ''; + return normalized.length > 0 && + normalized.length <= maxLength && + pattern.test(normalized) + ? normalized + : null; +} + +/** + * Reads the public performance-page handoff contract. Invalid or unexpected + * values are ignored so a pasted URL cannot force unsupported control state. + */ +export function parsePerformancePrefill(search: string) { + const params = new URLSearchParams(search); + return { + model: validParam(params.get('model'), MODEL_PATH, 200), + system: validParam(params.get('system'), SYSTEM_ID, 80), + }; +} diff --git a/docs/embeddable-sizing-widget-development.md b/docs/embeddable-sizing-widget-development.md new file mode 100644 index 0000000..2180648 --- /dev/null +++ b/docs/embeddable-sizing-widget-development.md @@ -0,0 +1,58 @@ +# Sizing widget developer guide + +Use this guide when changing the public sizing component. See the +[integration guide](./embeddable-sizing-widget.md) for host setup and API +examples. + +## Code map + +| Path | Purpose | +| --- | --- | +| `public/widgets/configiq-sizing-widget-v1.js` | Component, styles, validation, and request lifecycle | +| `tests/widgets/configiq-sizing-widget.test.mjs` | Public contract and lifecycle tests | +| `tests/widgets/fixtures/configiq-sizing-widget-host.html` | Cross-origin host preview | +| `next.config.js` | Widget CORS and cache headers | +| `app/performance/performance-prefill.ts` | Full-workflow query handoff | + +## Component boundaries + +The component owns its controls, validation, requests, status messages, +results, accessibility, and responsive layout. The host supplies catalog +mappings, seed values, an endpoint or proxy, theme tokens, and an optional link +to the full workflow. + +Use the `config` property for models, GPUs, and seed values. Keep attributes for +small settings such as endpoint, timeout, theme, heading level, and links. Use +documented `--configiq-widget-*` properties for host styling. + +## Preserve these behaviors + +- Map the six inputs through `buildSizingRequest`. +- Validate responses through `normalizeSizingResponse` before rendering. +- Cancel active requests and ignore late responses after an edit. +- Preserve in-progress edits when an equivalent `config` object arrives. +- Keep widget instances isolated. +- Accept only HTTP(S) full-workflow links. +- Keep status and metric labels available to assistive technology. + +The V1 URL is a mutable, backward-compatible channel. Browsers revalidate it, +and shared caches retain it for up to five minutes. Publish breaking API changes +under a new major component URL. + +## Validate a change + +Run the focused tests first, then the repository checks: + +```bash +npm test -- tests/widgets/configiq-sizing-widget.test.mjs +npm run type-check +npm run lint +npm run build +``` + +Add or update tests for every public property, attribute, state transition, or +failure path that changes. + +For visual changes, review the native and dark themes at 1440 and 375 pixels. +Capture every affected success, empty, loading, invalid, or unavailable state, +then update the images in `docs/screenshots/embeddable-sizing-widget/`. diff --git a/docs/embeddable-sizing-widget.md b/docs/embeddable-sizing-widget.md new file mode 100644 index 0000000..9410838 --- /dev/null +++ b/docs/embeddable-sizing-widget.md @@ -0,0 +1,167 @@ +# Embeddable sizing widget + +The ConfigIQ sizing widget adds performance sizing to any web page. The +framework-independent custom element collects six inputs and displays +throughput, time to first token (TTFT), and time per output token (TPOT) from +ConfigIQ. + +## Quick start + +Load the component from your ConfigIQ deployment, add it to the page, and pass +its model and GPU options through the `config` property. + +```html + + + +``` + +Set `configiqOrigin` to the deployment that serves the component. The same +origin can provide the optional link to the full ConfigIQ performance workflow. + +## Configuration + +| Setting | Purpose | +| --- | --- | +| `config.models` | Model labels, host identifiers, and ConfigIQ `model_path` values | +| `config.gpus` | GPU labels, host identifiers, and ConfigIQ `system` values | +| `config.seed` | Initial model, GPU, token, concurrency, and TTFT values | +| `endpoint` | Recommendation endpoint or same-origin server proxy | +| `timeout-ms` | Request timeout in milliseconds; the default is 95 seconds | +| `heading-level` | Heading level from `1` through `6`; the default is `2` | +| `theme` | Omit for the native light theme or set to `dark` | +| `full-url` | Optional HTTP(S) link to the full performance workflow | +| `full-label` | Optional label for the full-workflow link | + +The host maps its catalog identifiers to the `modelPath` and `system` values +that ConfigIQ accepts. The widget handles validation, requests, status updates, +results, and responsive presentation. + +When `full-url` is present, the widget keeps its `model` and `system` query +parameters aligned with the current selections. The `/performance` page reads +valid parameters and uses its catalog defaults for missing values. + +## Endpoint contract + +By default, the widget sends `POST /api/recommend`. Set `endpoint` when the page +uses a same-origin server proxy. + +```json +{ + "model_path": "Qwen/Qwen2.5-7B-Instruct", + "system": "h200_sxm", + "isl": 2048, + "osl": 512, + "ttft": 500, + "target_concurrency": 10 +} +``` + +The endpoint can return the ConfigIQ response directly or wrap it as +`{ "ok": true, "data": }`. The widget displays finite, +non-negative values from these fields: + +- `throughput.tokensPerSecond` +- `performance.ttftLatencyMs` +- `performance.tpotMs` + +The widget displays only values returned by ConfigIQ. An incomplete or failed +response produces an unavailable state. + +## Runtime behavior + +- Requests start 500 milliseconds after the last edit. +- A new edit cancels the previous request and ignores late responses. +- Token, concurrency, and latency fields accept positive integers. +- Reassigning an equivalent `config` object preserves in-progress edits. +- Changing the catalog or seed resets the fields to the new configuration. +- The versioned component URL provides backward-compatible updates for V1. + +## Styling + +The component uses an open shadow root to isolate its layout and styles. The +default theme matches ConfigIQ. Set `theme="dark"` for dark surfaces. + +Common supported properties include: + +- `--configiq-widget-surface` +- `--configiq-widget-surface-subtle` +- `--configiq-widget-text` +- `--configiq-widget-text-muted` +- `--configiq-widget-border` +- `--configiq-widget-accent` + +Use the `--configiq-widget-*` properties defined in the component as the +supported styling API. The component retains its spacing, hierarchy, behavior, +and accessible states across host themes. + +## Visual examples + +### Native theme + +![ConfigIQ sizing widget on desktop](./screenshots/embeddable-sizing-widget/configiq-native-success-1440.jpg) + +![ConfigIQ sizing widget on mobile](./screenshots/embeddable-sizing-widget/configiq-native-success-375.jpg) + +### Dark host theme + +![Sizing widget embedded in a dark desktop page](./screenshots/embeddable-sizing-widget/host-dark-1440.png) + +![Sizing widget embedded in a dark mobile page](./screenshots/embeddable-sizing-widget/host-dark-375.png) + +### Empty and unavailable states + +![Sizing widget waiting for model and GPU options](./screenshots/embeddable-sizing-widget/configiq-native-empty-1440.jpg) + +![Sizing widget showing an unavailable result](./screenshots/embeddable-sizing-widget/configiq-native-error-1440.jpg) + +## Test + +Run the component contract and lifecycle tests: + +```bash +npm test -- tests/widgets/configiq-sizing-widget.test.mjs +``` + +The tests cover request mapping, response validation, incomplete and error +states, stale requests, accessibility, configuration updates, and full-workflow +links. + +See the [developer guide](./embeddable-sizing-widget-development.md) for +component maintenance, compatibility, and visual-review checks. diff --git a/docs/screenshots/embeddable-sizing-widget/configiq-native-empty-1440.jpg b/docs/screenshots/embeddable-sizing-widget/configiq-native-empty-1440.jpg new file mode 100644 index 0000000..478528b Binary files /dev/null and b/docs/screenshots/embeddable-sizing-widget/configiq-native-empty-1440.jpg differ diff --git a/docs/screenshots/embeddable-sizing-widget/configiq-native-error-1440.jpg b/docs/screenshots/embeddable-sizing-widget/configiq-native-error-1440.jpg new file mode 100644 index 0000000..e0465af Binary files /dev/null and b/docs/screenshots/embeddable-sizing-widget/configiq-native-error-1440.jpg differ diff --git a/docs/screenshots/embeddable-sizing-widget/configiq-native-success-1440.jpg b/docs/screenshots/embeddable-sizing-widget/configiq-native-success-1440.jpg new file mode 100644 index 0000000..297b33f Binary files /dev/null and b/docs/screenshots/embeddable-sizing-widget/configiq-native-success-1440.jpg differ diff --git a/docs/screenshots/embeddable-sizing-widget/configiq-native-success-375.jpg b/docs/screenshots/embeddable-sizing-widget/configiq-native-success-375.jpg new file mode 100644 index 0000000..7dff66d Binary files /dev/null and b/docs/screenshots/embeddable-sizing-widget/configiq-native-success-375.jpg differ diff --git a/docs/screenshots/embeddable-sizing-widget/host-dark-1440.png b/docs/screenshots/embeddable-sizing-widget/host-dark-1440.png new file mode 100644 index 0000000..832cc27 Binary files /dev/null and b/docs/screenshots/embeddable-sizing-widget/host-dark-1440.png differ diff --git a/docs/screenshots/embeddable-sizing-widget/host-dark-375.png b/docs/screenshots/embeddable-sizing-widget/host-dark-375.png new file mode 100644 index 0000000..d077587 Binary files /dev/null and b/docs/screenshots/embeddable-sizing-widget/host-dark-375.png differ diff --git a/next.config.js b/next.config.js index d5d3bad..130a444 100644 --- a/next.config.js +++ b/next.config.js @@ -2,6 +2,18 @@ const nextConfig = { reactStrictMode: true, output: 'standalone', + async headers() { + return [ + { + source: '/widgets/:path*', + headers: [ + { key: 'Access-Control-Allow-Origin', value: '*' }, + { key: 'Cross-Origin-Resource-Policy', value: 'cross-origin' }, + { key: 'Cache-Control', value: 'public, max-age=0, s-maxage=300, must-revalidate' }, + ], + }, + ]; + }, transpilePackages: [ "@patternfly/react-core", "@patternfly/react-charts", diff --git a/package-lock.json b/package-lock.json index c11887d..149f67a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@types/react-dom": "^18.3.0", "eslint": "^8.57.0", "eslint-config-next": "^14.2.0", + "happy-dom": "^20.11.6", "husky": "^9.1.7", "lint-staged": "^17.0.5", "typescript": "^5.4.0", @@ -1518,6 +1519,23 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.59.3", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz", @@ -2538,6 +2556,19 @@ "concat-map": "0.0.1" } }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -3097,6 +3128,19 @@ "dev": true, "license": "MIT" }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/environment": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", @@ -4230,6 +4274,25 @@ "dev": true, "license": "MIT" }, + "node_modules/happy-dom": { + "version": "20.11.6", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.6.tgz", + "integrity": "sha512-Hldbg8AdAa5a5oDcZpjqnGitp7JB0hqWmfv/8qr+kft4vzSD8BHsbdRfzYvL/0QcbKcURC/yyoygbeDQarPvYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -7621,6 +7684,16 @@ } } }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index a995665..7a12c88 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@types/react-dom": "^18.3.0", "eslint": "^8.57.0", "eslint-config-next": "^14.2.0", + "happy-dom": "^20.11.6", "husky": "^9.1.7", "lint-staged": "^17.0.5", "typescript": "^5.4.0", diff --git a/public/widgets/configiq-sizing-widget-v1.js b/public/widgets/configiq-sizing-widget-v1.js new file mode 100644 index 0000000..0414c53 --- /dev/null +++ b/public/widgets/configiq-sizing-widget-v1.js @@ -0,0 +1,530 @@ +const DEFAULTS = Object.freeze({ + isl: 2048, + osl: 512, + concurrency: 10, + ttft: 500, +}); + +const HTMLElementBase = globalThis.HTMLElement ?? class {}; + +function escapeHtml(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function positiveInteger(value, fallback) { + const parsed = Math.floor(Number(value)); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function seedValue(seed, primary, alias, fallback) { + if (Object.hasOwn(seed, primary)) return seed[primary]; + if (alias && Object.hasOwn(seed, alias)) return seed[alias]; + return fallback; +} + +function strictPositiveInteger(value) { + const parsed = typeof value === 'number' ? value : Number(String(value).trim()); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; +} + +export function validateSizingValues(values) { + return ['isl', 'osl', 'concurrency', 'ttft'].filter( + (field) => strictPositiveInteger(values[field]) === null, + ); +} + +export function buildSizingRequest(config, values) { + const model = config.models.find((item) => item.value === values.model); + const gpu = config.gpus.find((item) => item.value === values.gpu); + + const isl = strictPositiveInteger(values.isl); + const osl = strictPositiveInteger(values.osl); + const ttft = strictPositiveInteger(values.ttft); + const concurrency = strictPositiveInteger(values.concurrency); + + if (!model?.modelPath || !gpu?.system || [isl, osl, ttft, concurrency].includes(null)) return null; + + return { + model_path: model.modelPath, + system: gpu.system, + isl, + osl, + ttft, + target_concurrency: concurrency, + }; +} + +export function normalizeSizingResponse(payload) { + if (payload?.ok === false) return null; + const isProxyWrapper = payload?.ok === true; + const candidate = isProxyWrapper ? payload.data : payload; + if (!candidate) return null; + if (!isProxyWrapper && candidate.status !== 'completed') return null; + if (isProxyWrapper && candidate.status !== undefined && candidate.status !== 'completed') return null; + + const tokensPerSecond = candidate.throughput?.tokensPerSecond; + const ttftLatencyMs = candidate.performance?.ttftLatencyMs; + const tpotMs = candidate.performance?.tpotMs; + + if (![tokensPerSecond, ttftLatencyMs, tpotMs].every((value) => Number.isFinite(value) && value >= 0)) { + return null; + } + + return { tokensPerSecond, ttftLatencyMs, tpotMs }; +} + +function optionMarkup(items, selected) { + return items.map((item) => ( + `` + )).join(''); +} + +function configSignature(config) { + const seed = config.seed; + return JSON.stringify({ + models: config.models.map((model) => ({ + value: model.value, + label: model.label, + modelPath: model.modelPath, + })), + gpus: config.gpus.map((gpu) => ({ + value: gpu.value, + label: gpu.label, + system: gpu.system, + })), + seed: { + model: seed.model, + modelId: seed.modelId, + gpu: seed.gpu, + gpuType: seed.gpuType, + isl: seed.isl, + islTokens: seed.islTokens, + osl: seed.osl, + oslTokens: seed.oslTokens, + concurrency: seed.concurrency, + ttft: seed.ttft, + ttftMs: seed.ttftMs, + }, + }); +} + +const styles = ` + :host { + --ciq-accent: var(--configiq-widget-accent, #0066cc); + --ciq-accent-strong: var(--configiq-widget-accent-strong, #004b95); + --ciq-accent-soft: var(--configiq-widget-accent-soft, #e7f1fa); + --ciq-teal: var(--configiq-widget-secondary-accent, #007a87); + --ciq-ink: var(--configiq-widget-text, #151515); + --ciq-muted: var(--configiq-widget-text-muted, #3c3f42); + --ciq-caption: var(--configiq-widget-text-caption, #54585c); + --ciq-border: var(--configiq-widget-border, #d2d2d2); + --ciq-surface: var(--configiq-widget-surface, #ffffff); + --ciq-surface-subtle: var(--configiq-widget-surface-subtle, #f7f7f8); + --ciq-control: var(--configiq-widget-control, #ffffff); + --ciq-control-border: var(--configiq-widget-control-border, #8a8d90); + --ciq-header: var(--configiq-widget-header, linear-gradient(115deg, #002f5d 0%, #005f73 100%)); + --ciq-header-text: var(--configiq-widget-header-text, #ffffff); + --ciq-header-muted: var(--configiq-widget-header-muted, #eef7fb); + --ciq-eyebrow: var(--configiq-widget-eyebrow, #c9e8ff); + --ciq-chevron: var(--configiq-widget-chevron, #3c3f42); + --ciq-error-surface: var(--configiq-widget-error-surface, #fff8e5); + --ciq-error-text: var(--configiq-widget-error-text, #4f3800); + --ciq-shadow: var(--configiq-widget-shadow, 0 8px 24px rgb(21 21 21 / 8%)); + --ciq-focus-ring: var(--configiq-widget-focus-ring, rgb(0 102 204 / 18%)); + color: var(--ciq-ink); + display: block; + font-family: "Red Hat Text", "Plus Jakarta Sans", system-ui, sans-serif; + line-height: 1.5; + } + :host([theme="dark"]) { + --ciq-accent: var(--configiq-widget-accent, #7c73ff); + --ciq-accent-strong: var(--configiq-widget-accent-strong, #c4c0ff); + --ciq-accent-soft: var(--configiq-widget-accent-soft, #292742); + --ciq-teal: var(--configiq-widget-secondary-accent, #6bb8bd); + --ciq-ink: var(--configiq-widget-text, #f5f5f5); + --ciq-muted: var(--configiq-widget-text-muted, #d1d1d1); + --ciq-caption: var(--configiq-widget-text-caption, #b8b8b8); + --ciq-border: var(--configiq-widget-border, #484848); + --ciq-surface: var(--configiq-widget-surface, #252525); + --ciq-surface-subtle: var(--configiq-widget-surface-subtle, #202020); + --ciq-control: var(--configiq-widget-control, #252525); + --ciq-control-border: var(--configiq-widget-control-border, #484848); + --ciq-header: var(--configiq-widget-header, #252525); + --ciq-header-text: var(--configiq-widget-header-text, #f5f5f5); + --ciq-header-muted: var(--configiq-widget-header-muted, #d1d1d1); + --ciq-eyebrow: var(--configiq-widget-eyebrow, #d1d1d1); + --ciq-chevron: var(--configiq-widget-chevron, #b8b8b8); + --ciq-error-surface: var(--configiq-widget-error-surface, #3a321f); + --ciq-error-text: var(--configiq-widget-error-text, #ffe8a3); + --ciq-shadow: var(--configiq-widget-shadow, none); + } + * { box-sizing: border-box; } + .shell { + background: var(--ciq-surface); + border: 1px solid var(--ciq-border); + border-radius: 6px; + box-shadow: var(--ciq-shadow); + overflow: hidden; + } + .header { + background: var(--ciq-header); + color: var(--ciq-header-text); + display: grid; + gap: 6px; + grid-template-areas: + "eyebrow link" + "title link" + "intro link"; + grid-template-columns: minmax(0, 1fr) auto; + padding: 20px; + } + .header-top { display: contents; } + .eyebrow { + color: var(--ciq-eyebrow); + font-family: "Red Hat Mono", "JetBrains Mono", monospace; + font-size: 12px; + font-weight: 600; + letter-spacing: .07em; + text-transform: uppercase; + grid-area: eyebrow; + } + .full-link { + border: 1px solid currentColor; + border-radius: 6px; + color: var(--ciq-header-text); + font-size: 12.5px; + font-weight: 600; + padding: 6px 10px; + text-decoration: none; + white-space: nowrap; + } + .full-link-slot:empty { display: none; } + .full-link-slot { align-self: start; grid-area: link; margin-left: 12px; } + .full-link:hover { background: rgb(255 255 255 / 10%); } + .full-link:focus-visible { outline: 3px solid var(--ciq-accent); outline-offset: 2px; } + .title { font-size: 22px; font-weight: 600; grid-area: title; line-height: 1.2; margin: 0; } + .intro { color: var(--ciq-header-muted); font-size: 14px; grid-area: intro; margin: 0; max-width: 72ch; } + .content { display: grid; gap: 20px; padding: 20px; } + .inputs { display: grid; gap: 16px; grid-template-columns: repeat(12, minmax(0, 1fr)); } + .field { display: grid; gap: 6px; grid-column: span 3; } + .field.wide { grid-column: span 6; } + label { color: var(--ciq-ink); font-size: 13px; font-weight: 600; } + input, select { + appearance: none; + background: var(--ciq-control); + border: 1px solid var(--ciq-control-border); + border-radius: 6px; + color: var(--ciq-ink); + font: inherit; + font-size: 14px; + font-variant-numeric: tabular-nums; + min-height: 42px; + padding: 9px 11px; + width: 100%; + } + select { + background-image: linear-gradient(45deg, transparent 50%, var(--ciq-chevron) 50%), linear-gradient(135deg, var(--ciq-chevron) 50%, transparent 50%); + background-position: calc(100% - 16px) 18px, calc(100% - 11px) 18px; + background-repeat: no-repeat; + background-size: 5px 5px, 5px 5px; + padding-right: 32px; + } + input:focus, select:focus { border-color: var(--ciq-accent); box-shadow: 0 0 0 3px var(--ciq-focus-ring); outline: 0; } + .hint { color: var(--ciq-caption); font-size: 11.5px; line-height: 1.35; } + .results { border-top: 1px solid var(--ciq-border); padding-top: 20px; } + .status { + background: var(--ciq-surface-subtle); + border-left: 4px solid var(--ciq-accent); + color: var(--ciq-muted); + font-size: 14px; + margin: 0; + padding: 14px 16px; + } + .status.error { background: var(--ciq-error-surface); border-color: #f0ab00; color: var(--ciq-error-text); } + .stats { display: grid; gap: 12px; grid-template-columns: repeat(3, minmax(0, 1fr)); } + .stat { + background: var(--ciq-surface-subtle); + border: 1px solid var(--ciq-border); + border-top: 3px solid var(--ciq-teal); + border-radius: 6px; + display: grid; + gap: 4px; + padding: 16px; + } + .stat.primary { background: var(--ciq-accent-soft); border-color: var(--ciq-accent); border-top-color: var(--ciq-accent); } + .value { + color: var(--ciq-ink); + font-family: "Red Hat Display", "Plus Jakarta Sans", sans-serif; + font-size: 28px; + font-variant-numeric: tabular-nums; + font-weight: 700; + line-height: 1.1; + } + .unit { color: var(--ciq-muted); font-family: "Red Hat Text", sans-serif; font-size: 13px; font-weight: 500; margin-left: 5px; white-space: nowrap; } + .metric { color: var(--ciq-muted); font-size: 12.5px; font-weight: 600; } + @media (max-width: 720px) { + .header, .content { padding: 18px; } + .header { + grid-template-areas: + "eyebrow" + "title" + "intro" + "link"; + grid-template-columns: minmax(0, 1fr); + } + .full-link-slot { margin: 8px 0 0; } + .inputs { grid-template-columns: 1fr; } + .field, .field.wide { grid-column: auto; } + .stats { grid-template-columns: 1fr; } + } +`; + +function fieldMarkup({ id, field, label, hint, value, max = 1000000 }) { + const hintId = `${id}-hint`; + return ` +
+ + + ${escapeHtml(hint)} +
`; +} + +export class ConfigIqSizingWidget extends HTMLElementBase { + #config = { models: [], gpus: [], seed: {} }; + #requestToken = 0; + #timer = null; + #controller = null; + #configSignature = ''; + + constructor() { + super(); + if (this.attachShadow) this.attachShadow({ mode: 'open' }); + } + + static get observedAttributes() { return ['full-url', 'full-label', 'heading-level']; } + + attributeChangedCallback(name, oldValue, newValue) { + if (oldValue === newValue || !this.isConnected) return; + if (name === 'heading-level') this.renderHeadingLevel(); + else this.renderFullLink(); + } + + set config(value) { + const nextConfig = { + models: Array.isArray(value?.models) ? value.models : [], + gpus: Array.isArray(value?.gpus) ? value.gpus : [], + seed: value?.seed && typeof value.seed === 'object' ? value.seed : {}, + }; + const nextSignature = configSignature(nextConfig); + if (nextSignature === this.#configSignature) return; + this.#config = nextConfig; + this.#configSignature = nextSignature; + if (this.isConnected) this.render(); + } + + get config() { return this.#config; } + + headingLevel() { + const level = Number(this.getAttribute('heading-level') || 2); + return Number.isInteger(level) && level >= 1 && level <= 6 ? level : 2; + } + + connectedCallback() { this.render(); } + + disconnectedCallback() { + if (this.#timer) clearTimeout(this.#timer); + this.#controller?.abort(); + this.#timer = null; + this.#controller = null; + } + + render() { + if (!this.shadowRoot) return; + const seed = this.#config.seed; + const selectedModel = seed.model ?? seed.modelId ?? ''; + const selectedGpu = seed.gpu ?? seed.gpuType ?? ''; + this.shadowRoot.innerHTML = ` + +
+
+
+ IQ Configurator + ${this.fullLinkMarkup()} +
+
Performance sizing
+

Adjust an input to refresh throughput and latency.

+
+
+
+
+ + +
+
+ + +
+ ${fieldMarkup({ id: 'isl', field: 'isl', label: 'Input tokens', hint: 'Typical prompt length.', value: seedValue(seed, 'isl', 'islTokens', DEFAULTS.isl) })} + ${fieldMarkup({ id: 'osl', field: 'osl', label: 'Output tokens', hint: 'Typical response length.', value: seedValue(seed, 'osl', 'oslTokens', DEFAULTS.osl) })} + ${fieldMarkup({ id: 'concurrency', field: 'concurrency', label: 'Target concurrency', hint: 'Requests running at the same time.', value: seedValue(seed, 'concurrency', null, DEFAULTS.concurrency) })} + ${fieldMarkup({ id: 'ttft', field: 'ttft', label: 'Target time to first token (ms)', hint: 'Maximum time to the first token.', value: seedValue(seed, 'ttft', 'ttftMs', DEFAULTS.ttft), max: 600000 })} +
+
+
+
`; + + this.shadowRoot.querySelectorAll('[data-field]').forEach((input) => { + input.addEventListener(input.tagName === 'SELECT' ? 'change' : 'input', () => this.schedule()); + }); + this.schedule(0); + } + + fullUrl() { + const value = this.getAttribute('full-url'); + if (!value) return null; + try { + const baseUrl = globalThis.document?.baseURI || globalThis.location?.href; + const parsed = new URL(value, baseUrl); + if (!['http:', 'https:'].includes(parsed.protocol)) return null; + const values = this.values(); + const modelValue = values.model || this.#config.seed.model || this.#config.seed.modelId; + const gpuValue = values.gpu || this.#config.seed.gpu || this.#config.seed.gpuType; + const model = this.#config.models.find((item) => item.value === modelValue); + const gpu = this.#config.gpus.find((item) => item.value === gpuValue); + if (model?.modelPath) parsed.searchParams.set('model', model.modelPath); + if (gpu?.system) parsed.searchParams.set('system', gpu.system); + return parsed.href; + } catch { + return null; + } + } + + fullLinkMarkup() { + const fullUrl = this.fullUrl(); + if (!fullUrl) return ''; + const label = this.getAttribute('full-label') || 'Open full Configurator'; + return `${escapeHtml(label)} ↗`; + } + + renderFullLink() { + const slot = this.shadowRoot?.querySelector('.full-link-slot'); + if (slot) slot.innerHTML = this.fullLinkMarkup(); + } + + renderHeadingLevel() { + this.shadowRoot?.querySelector('.title') + ?.setAttribute('aria-level', String(this.headingLevel())); + } + + values() { + const value = (name) => this.shadowRoot?.querySelector(`[data-field="${name}"]`)?.value ?? ''; + return { + model: value('model'), gpu: value('gpu'), isl: value('isl'), osl: value('osl'), + concurrency: value('concurrency'), ttft: value('ttft'), + }; + } + + schedule(delay = 500) { + this.#requestToken += 1; + const token = this.#requestToken; + if (this.#timer) clearTimeout(this.#timer); + this.#timer = null; + this.#controller?.abort(); + this.#controller = null; + this.renderFullLink(); + + const values = this.values(); + const invalidFields = validateSizingValues(values); + this.shadowRoot?.querySelectorAll('input[data-field]').forEach((input) => { + input.setAttribute('aria-invalid', String(invalidFields.includes(input.dataset.field))); + }); + if (!values.model || !values.gpu) { + this.showStatus(values.model ? 'Choose a GPU to continue.' : 'Choose a model to continue.'); + return; + } + + if (invalidFields.length) { + this.showInputError(); + return; + } + + this.showStatus('Calculating…'); + this.#timer = setTimeout(() => void this.request(values, token), delay); + } + + async request(values, token) { + const payload = buildSizingRequest(this.#config, values); + if (!payload) { + this.showError(); + return; + } + + const controller = new AbortController(); + this.#controller = controller; + const timeoutMs = positiveInteger(this.getAttribute('timeout-ms'), 95000); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(this.getAttribute('endpoint') || '/api/recommend', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + const body = response.ok ? await response.json() : null; + const result = normalizeSizingResponse(body); + if (token !== this.#requestToken) return; + if (!result) this.showError(); + else this.showResult(result); + } catch { + if (token === this.#requestToken) this.showError(); + } finally { + clearTimeout(timeout); + if (this.#controller === controller) this.#controller = null; + } + } + + showStatus(message) { + const results = this.shadowRoot?.querySelector('.results'); + if (results) results.innerHTML = `

${escapeHtml(message)}

`; + } + + showError() { + const results = this.shadowRoot?.querySelector('.results'); + if (results) results.innerHTML = '

Sizing is unavailable. Try again in a moment.

'; + } + + showInputError() { + const results = this.shadowRoot?.querySelector('.results'); + if (results) results.innerHTML = '

Enter a positive whole number in every numeric field to request sizing.

'; + } + + showResult(result) { + const metric = (label, value, unit, primary = false) => ` +
+ ${Math.round(value)}${escapeHtml(unit)} + ${escapeHtml(label)} +
`; + const results = this.shadowRoot?.querySelector('.results'); + if (results) results.innerHTML = ` +
+ ${metric('Throughput', result.tokensPerSecond, 'tokens/s', true)} + ${metric('Time to first token', result.ttftLatencyMs, 'ms')} + ${metric('Time per output token', result.tpotMs, 'ms')} +
`; + } +} + +if (globalThis.customElements && !globalThis.customElements.get('configiq-sizing-widget')) { + globalThis.customElements.define('configiq-sizing-widget', ConfigIqSizingWidget); +} diff --git a/tests/widgets/configiq-sizing-widget.test.mjs b/tests/widgets/configiq-sizing-widget.test.mjs new file mode 100644 index 0000000..43f498b --- /dev/null +++ b/tests/widgets/configiq-sizing-widget.test.mjs @@ -0,0 +1,345 @@ +// @vitest-environment happy-dom + +import { describe, expect, it } from 'vitest'; +import { + buildSizingRequest, + normalizeSizingResponse, + validateSizingValues, +} from '../../public/widgets/configiq-sizing-widget-v1.js'; + +const config = { + models: [{ value: 'qwen', label: 'Qwen 2.5 7B', modelPath: 'Qwen/Qwen2.5-7B-Instruct' }], + gpus: [{ value: 'h200', label: 'NVIDIA H200', system: 'h200_sxm' }], +}; + +function mountWidget(overrides = {}) { + const widget = document.createElement('configiq-sizing-widget'); + widget.config = { + ...config, + seed: { model: 'qwen', gpu: 'h200', isl: 2048, osl: 512, concurrency: 10, ttft: 500 }, + ...overrides, + }; + widget.setAttribute('endpoint', '/api/configiq'); + document.body.append(widget); + return widget; +} + +function deferred() { + let resolve; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +describe('ConfigIQ sizing widget contract', () => { + it('maps the six user inputs to the ConfigIQ request contract', () => { + expect(buildSizingRequest(config, { + model: 'qwen', gpu: 'h200', isl: '2048', osl: 512, + concurrency: 10, ttft: 500, + })).toEqual({ + model_path: 'Qwen/Qwen2.5-7B-Instruct', + system: 'h200_sxm', + isl: 2048, + osl: 512, + ttft: 500, + target_concurrency: 10, + }); + }); + + it('does not build a request until both model and GPU resolve', () => { + expect(buildSizingRequest(config, { model: 'qwen', gpu: '' })).toBeNull(); + expect(buildSizingRequest(config, { model: '', gpu: 'h200' })).toBeNull(); + expect(buildSizingRequest(config, { model: 'unknown', gpu: 'h200' })).toBeNull(); + }); + + it('rejects invalid live numeric values instead of substituting hidden defaults', () => { + for (const invalid of ['', 0, -1, 1.5, '2.5']) { + const values = { model: 'qwen', gpu: 'h200', isl: invalid, osl: 512, concurrency: 10, ttft: 500 }; + expect(buildSizingRequest(config, values)).toBeNull(); + expect(validateSizingValues(values)).toContain('isl'); + } + }); + + it('accepts direct ConfigIQ responses and host-proxy wrappers', () => { + const data = { + status: 'completed', + throughput: { tokensPerSecond: 7156.9 }, + performance: { ttftLatencyMs: 145.2, tpotMs: 28.8 }, + }; + const expected = { tokensPerSecond: 7156.9, ttftLatencyMs: 145.2, tpotMs: 28.8 }; + expect(normalizeSizingResponse(data)).toEqual(expected); + expect(normalizeSizingResponse({ ok: true, data })).toEqual(expected); + }); + + it('fails closed on errors, missing values, and negative metrics', () => { + expect(normalizeSizingResponse({ status: 'failed' })).toBeNull(); + expect(normalizeSizingResponse({ ok: false })).toBeNull(); + expect(normalizeSizingResponse({ + throughput: { tokensPerSecond: -1 }, + performance: { ttftLatencyMs: 145, tpotMs: 29 }, + })).toBeNull(); + expect(normalizeSizingResponse({ throughput: {}, performance: {} })).toBeNull(); + expect(normalizeSizingResponse({ + status: 'pending', + throughput: { tokensPerSecond: 7157 }, + performance: { ttftLatencyMs: 145, tpotMs: 29 }, + })).toBeNull(); + expect(normalizeSizingResponse({ + status: 'completed', + throughput: { tokensPerSecond: null }, + performance: { ttftLatencyMs: '', tpotMs: false }, + })).toBeNull(); + expect(normalizeSizingResponse({ + ok: true, + data: { + status: 'failed', + throughput: { tokensPerSecond: 7157 }, + performance: { ttftLatencyMs: 145, tpotMs: 29 }, + }, + })).toBeNull(); + expect(normalizeSizingResponse({ + ok: true, + data: { + status: null, + throughput: { tokensPerSecond: 7157 }, + performance: { ttftLatencyMs: 145, tpotMs: 29 }, + }, + })).toBeNull(); + }); +}); + +describe('ConfigIQ sizing widget lifecycle', () => { + beforeEach(() => { + document.body.innerHTML = ''; + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('renders exactly the six documented inputs and associated help', () => { + const widget = mountWidget(); + const fields = [...widget.shadowRoot.querySelectorAll('[data-field]')].map((item) => item.dataset.field); + expect(fields).toEqual(['model', 'gpu', 'isl', 'osl', 'concurrency', 'ttft']); + expect(widget.shadowRoot.textContent).not.toMatch(/parameters|precision/i); + for (const control of widget.shadowRoot.querySelectorAll('input[data-field]')) { + expect(control.getAttribute('aria-describedby')).toBeTruthy(); + expect(widget.shadowRoot.getElementById(control.getAttribute('aria-describedby'))).toBeTruthy(); + } + expect(widget.shadowRoot.textContent).toContain('Adjust an input to refresh throughput and latency.'); + }); + + it('supports a dark host theme and an optional configurable full-tool link', () => { + const widget = mountWidget(); + widget.setAttribute('theme', 'dark'); + widget.setAttribute('full-url', 'https://configiq.example/performance?model=qwen'); + widget.setAttribute('full-label', 'Open detailed sizing'); + const link = widget.shadowRoot.querySelector('.full-link'); + expect(widget.getAttribute('theme')).toBe('dark'); + expect(link.href).toContain('model=Qwen%2FQwen2.5-7B-Instruct'); + expect(link.href).toContain('system=h200_sxm'); + expect(link.textContent).toContain('Open detailed sizing'); + expect(link.getAttribute('target')).toBe('_blank'); + expect(link.getAttribute('rel')).toContain('noopener'); + }); + + it('updates heading hierarchy without resetting edits or requesting again', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ ok: true, data: { throughput: { tokensPerSecond: 100 }, performance: { ttftLatencyMs: 20, tpotMs: 5 } } }), + }); + vi.stubGlobal('fetch', fetchMock); + const widget = mountWidget(); + await vi.advanceTimersByTimeAsync(0); + const concurrency = widget.shadowRoot.querySelector('[data-field="concurrency"]'); + concurrency.value = '37'; + widget.setAttribute('heading-level', '1'); + let title = widget.shadowRoot.querySelector('.title'); + expect(title.getAttribute('role')).toBe('heading'); + expect(title.getAttribute('aria-level')).toBe('1'); + expect(widget.shadowRoot.querySelector('[data-field="concurrency"]').value).toBe('37'); + widget.setAttribute('heading-level', '99'); + title = widget.shadowRoot.querySelector('.title'); + expect(title.getAttribute('aria-level')).toBe('2'); + await vi.advanceTimersByTimeAsync(1000); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('keeps the default full-tool label concise', () => { + const widget = mountWidget(); + widget.setAttribute('full-url', 'https://configiq.example/performance'); + expect(widget.shadowRoot.querySelector('.full-link').textContent).toContain('Open full Configurator'); + }); + + it('resolves a relative full-tool link against the embedding document', () => { + const widget = mountWidget(); + widget.setAttribute('full-url', '/performance'); + expect(widget.shadowRoot.querySelector('.full-link').origin).toBe(new URL(document.baseURI).origin); + }); + + it('keeps the full-tool handoff aligned with edited selections', () => { + const widget = mountWidget(); + widget.setAttribute('full-url', 'https://configiq.example/performance'); + widget.config = { + models: [ + ...config.models, + { value: 'other', label: 'Other model', modelPath: 'example/other' }, + ], + gpus: config.gpus, + seed: { model: 'qwen', gpu: 'h200', isl: 2048, osl: 512, concurrency: 10, ttft: 500 }, + }; + const model = widget.shadowRoot.querySelector('[data-field="model"]'); + model.value = 'other'; + model.dispatchEvent(new Event('change')); + expect(widget.shadowRoot.querySelector('.full-link').href).toContain('model=example%2Fother'); + }); + + it('does not render unsafe full-tool link protocols', () => { + const widget = mountWidget(); + widget.setAttribute('full-url', 'javascript:alert(1)'); + expect(widget.shadowRoot.querySelector('.full-link')).toBeNull(); + }); + + it('keeps equivalent reactive config assignments from resetting user edits', () => { + const widget = mountWidget(); + const concurrency = widget.shadowRoot.querySelector('[data-field="concurrency"]'); + concurrency.value = '37'; + widget.config = { + gpus: widget.config.gpus.map(({ value, label, system }) => ({ system, label, value })), + seed: { + ttft: 500, + concurrency: 10, + osl: 512, + isl: 2048, + gpu: 'h200', + model: 'qwen', + }, + models: widget.config.models.map(({ value, label, modelPath }) => ({ modelPath, label, value })), + }; + expect(widget.shadowRoot.querySelector('[data-field="concurrency"]').value).toBe('37'); + }); + + it('applies intentional catalog reordering', () => { + const widget = mountWidget(); + widget.config = { + models: [ + ...config.models, + { value: 'other', label: 'Other model', modelPath: 'example/other' }, + ], + gpus: config.gpus, + seed: { model: 'qwen', gpu: 'h200', isl: 2048, osl: 512, concurrency: 10, ttft: 500 }, + }; + const originalModelOrder = widget.config.models.map(({ value }) => value); + const originalGpuOrder = widget.config.gpus.map(({ value }) => value); + widget.config = { + models: [...widget.config.models].reverse(), + gpus: [...widget.config.gpus].reverse(), + seed: { ...widget.config.seed }, + }; + expect(widget.config.models.map(({ value }) => value)).toEqual([...originalModelOrder].reverse()); + expect(widget.config.gpus.map(({ value }) => value)).toEqual([...originalGpuOrder].reverse()); + expect( + [...widget.shadowRoot.querySelectorAll('[data-field="model"] option')] + .slice(1) + .map(({ value }) => value), + ).toEqual([...originalModelOrder].reverse()); + }); + + it('gives every result an accessible metric label', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + ok: true, + data: { + throughput: { tokensPerSecond: 7156.9 }, + performance: { ttftLatencyMs: 145.2, tpotMs: 28.8 }, + }, + }), + })); + const widget = mountWidget(); + await vi.advanceTimersByTimeAsync(0); + const metrics = [...widget.shadowRoot.querySelectorAll('[role="listitem"]')]; + expect(metrics).toHaveLength(3); + expect(metrics.map((item) => item.getAttribute('aria-label'))).toEqual([ + 'Throughput: 7157 tokens/s', + 'Time to first token: 145 ms', + 'Time per output token: 29 ms', + ]); + }); + + it('keeps widget instances isolated', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ ok: true, data: { throughput: { tokensPerSecond: 100 }, performance: { ttftLatencyMs: 20, tpotMs: 5 } } }), + }); + vi.stubGlobal('fetch', fetchMock); + const first = mountWidget(); + const second = mountWidget({ seed: { model: 'qwen', gpu: 'h200', isl: 4096, osl: 256, concurrency: 20, ttft: 800 } }); + await vi.advanceTimersByTimeAsync(0); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(first.shadowRoot.textContent).toContain('100'); + expect(second.shadowRoot.textContent).toContain('100'); + }); + + it('preserves invalid host seeds and blocks the request', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const widget = mountWidget({ + seed: { model: 'qwen', gpu: 'h200', isl: 0, osl: 512, concurrency: 10, ttft: 500 }, + }); + await vi.advanceTimersByTimeAsync(1000); + const input = widget.shadowRoot.querySelector('[data-field="isl"]'); + expect(input.value).toBe('0'); + expect(input.getAttribute('aria-invalid')).toBe('true'); + expect(widget.shadowRoot.textContent).toContain('positive whole number'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('clears stale numeric accessibility errors while a select is empty', () => { + const widget = mountWidget(); + const model = widget.shadowRoot.querySelector('[data-field="model"]'); + const input = widget.shadowRoot.querySelector('[data-field="isl"]'); + input.value = '0'; + input.dispatchEvent(new Event('input')); + expect(input.getAttribute('aria-invalid')).toBe('true'); + model.value = ''; + model.dispatchEvent(new Event('change')); + input.value = '1'; + input.dispatchEvent(new Event('input')); + expect(input.getAttribute('aria-invalid')).toBe('false'); + }); + + it('drops an out-of-order response after the user edits an input', async () => { + const oldRequest = deferred(); + const newRequest = deferred(); + const fetchMock = vi.fn() + .mockReturnValueOnce(oldRequest.promise) + .mockReturnValueOnce(newRequest.promise); + vi.stubGlobal('fetch', fetchMock); + const widget = mountWidget(); + await vi.advanceTimersByTimeAsync(0); + + const concurrency = widget.shadowRoot.querySelector('[data-field="concurrency"]'); + concurrency.value = '20'; + concurrency.dispatchEvent(new Event('input')); + await vi.advanceTimersByTimeAsync(500); + + newRequest.resolve({ + ok: true, + json: async () => ({ ok: true, data: { throughput: { tokensPerSecond: 200 }, performance: { ttftLatencyMs: 30, tpotMs: 6 } } }), + }); + await Promise.resolve(); + await Promise.resolve(); + expect(widget.shadowRoot.textContent).toContain('200'); + + oldRequest.resolve({ + ok: true, + json: async () => ({ ok: true, data: { throughput: { tokensPerSecond: 50 }, performance: { ttftLatencyMs: 90, tpotMs: 12 } } }), + }); + await Promise.resolve(); + await Promise.resolve(); + expect(widget.shadowRoot.textContent).toContain('200'); + expect(widget.shadowRoot.textContent).not.toContain('50tokens/s'); + }); +}); diff --git a/tests/widgets/fixtures/configiq-sizing-widget-host.html b/tests/widgets/fixtures/configiq-sizing-widget-host.html new file mode 100644 index 0000000..85d4aa4 --- /dev/null +++ b/tests/widgets/fixtures/configiq-sizing-widget-host.html @@ -0,0 +1,82 @@ + + + + + + ConfigIQ sizing widget host preview + + + +
+ Dark host · component preview +

Performance sizing in a host page

+ +
+ + +