Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions app/performance/PerformanceEstimate.prefill.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import('@/lib/app-config')>();
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 }) => (
<label>Model<input id={id} aria-label="Model" value={model} onChange={(event) => onChange(event.target.value)} /></label>
),
}));

vi.mock('@/components/ui/GpuSystemInput', () => ({
GpuSystemInput: ({ id, value, onChange, gpuOptions }: {
id: string;
value: string;
onChange: (value: string) => void;
gpuOptions: Array<{ systemId: string; displayName: string }>;
}) => (
<label>GPU<select id={id} aria-label="GPU system" value={value} onChange={(event) => onChange(event.target.value)}>
{gpuOptions.map((gpu) => <option key={gpu.systemId} value={gpu.systemId}>{gpu.displayName}</option>)}
</select></label>
),
}));

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) => <div>{children}</div>,
InfoStripAction: ({ children }: React.PropsWithChildren) => <span>{children}</span>,
}));

import PerformanceEstimate from './PerformanceEstimate';

let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;

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(<PerformanceEstimate />);
await Promise.resolve();
});
return {
model: container.querySelector<HTMLInputElement>('#qe-model'),
gpu: container.querySelector<HTMLSelectElement>('#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');
});
});
20 changes: 15 additions & 5 deletions app/performance/PerformanceEstimate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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<string[]>([]);
Expand Down
20 changes: 20 additions & 0 deletions app/performance/performance-prefill.test.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
});
23 changes: 23 additions & 0 deletions app/performance/performance-prefill.ts
Original file line number Diff line number Diff line change
@@ -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),
};
}
58 changes: 58 additions & 0 deletions docs/embeddable-sizing-widget-development.md
Original file line number Diff line number Diff line change
@@ -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/`.
Loading