From 71beb85bf35ad1f147f3c82d73231e2df271eeda Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 00:52:01 +0300 Subject: [PATCH 01/35] fix(site): render the wizard's Compliance step again STEP_RENDERERS['compliance'] called appendDetail, which only the dataset, training and operations renderers declared as local helpers. Every visit threw a ReferenceError after the risk select and before the Article 9 risk-assessment textareas, the vulnerable-groups row and the benchmark and LLM-judge controls, so strict tiers could never pass the step and the controller's counter, YAML preview and saved state stopped for it. Declare appendDetail once at the wizard's shared scope, taking the state explicitly, and delete the three local copies. Extract the VM + DOM-shim seam into tests/site/wizard_harness.cjs so every browser-wizard test runs the shipped renderers through one shim, and add render smoke coverage for every step across all risk tiers, both detail modes and every optional gate. The new tests fail on the previous wizard.js (12/12, "appendDetail is not defined") and pass now. Finding: F-W1-SITE-01. Validation: node --test tests/site/*.test.cjs (43 pass); headless Chromium audit of 10 scenarios x 9 steps on the served site: no exceptions, counter / YAML preview / persisted step consistent on every step, and a keyboard-only high-risk Compliance flow advances to Operations once filled. Fixes #879 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 + docs/standards/testing.md | 5 +- site/js/wizard.js | 77 ++++++-------- tests/site/wizard_harness.cjs | 93 +++++++++++++++++ tests/site/wizard_monitoring.test.cjs | 42 +------- tests/site/wizard_render.test.cjs | 145 ++++++++++++++++++++++++++ 6 files changed, 278 insertions(+), 88 deletions(-) create mode 100644 tests/site/wizard_harness.cjs create mode 100644 tests/site/wizard_render.test.cjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 818f6667..15b99eff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,10 @@ All notable changes to ForgeLM are documented here. ### Fixed +- Render the browser wizard's Compliance step again. It called a helper that only three other step + renderers declared, so every visit threw a `ReferenceError` before the Article 9 risk-assessment, + benchmark and LLM-judge controls appeared, and strict risk tiers could not complete the step. + The helper is now shared, and every step has DOM-level render coverage (#879). - Restore the monitoring-plan evidence contract: `monitoring.*` declares an external plan, labelled `declarative_only: true`; ForgeLM runs no exporter, drift detector, alert, or scheduler. Warn once per config-file load for an enabled exporter declaration, without repeating during diff --git a/docs/standards/testing.md b/docs/standards/testing.md index 68398aed..f1736ec1 100644 --- a/docs/standards/testing.md +++ b/docs/standards/testing.md @@ -68,8 +68,9 @@ Python environment available. The harness uses the active virtual environment, t CI runs them on Node 24 in the validation job after installing the Python development dependencies. They use Node's built-in test runner and a VM to execute the actual wizard; generated YAML is also parsed by the real `ForgeConfig`. No npm packages, -browser download, GPU, or network calls are needed. DOM shims exercise the -Operations renderer only; they do not replace browser or native-language review. +browser download, GPU, or network calls are needed. The shared harness +(`tests/site/wizard_harness.cjs`) renders every step through a minimal DOM +shim; it does not replace browser or native-language review. ## Fixtures diff --git a/site/js/wizard.js b/site/js/wizard.js index 687bd763..3ac40e6b 100644 --- a/site/js/wizard.js +++ b/site/js/wizard.js @@ -1454,6 +1454,18 @@ return nodes; } + // Append a per-field detail paragraph beneath a row while the operator + // has details enabled (state.detailsVisible). The paragraph expands what + // the hint glosses: 2-3 sentences on what the value MEANS, not just what + // range to pick. Shared by every step renderer — it used to be a local + // helper in three of them, and the compliance renderer, which had none, + // threw a ReferenceError before its risk and evaluation controls (#879). + function appendDetail(state, rowEl, detailKey) { + if (state.detailsVisible && detailKey) { + rowEl.appendChild(el('p', { class: 'wizard-row-detail', text: tr(detailKey) })); + } + } + /* Step 1: Welcome / experience level */ STEP_RENDERERS['welcome'] = function (pane, state, rerender, persist) { pane.appendChild(el('h2', { class: 'wizard-step-title', id: 'wizard-title', text: tr('wizard.step.welcome.title') })); @@ -1730,11 +1742,6 @@ var yamlOutput = document.querySelector('[data-wizard-yaml-output]'); if (yamlOutput) yamlOutput.innerHTML = tintYaml(buildYaml(state)); } - function appendDetail(rowEl, detailKey) { - if (state.detailsVisible && detailKey) { - rowEl.appendChild(el('p', { class: 'wizard-row-detail', text: tr(detailKey) })); - } - } // Data Governance accordion (EU AI Act Article 10). pane.appendChild(makeAccordion(state, persist, 'governance', @@ -1774,7 +1781,7 @@ } } }); - appendDetail(row, detailKey); + appendDetail(state, row, detailKey); return row; } @@ -1832,16 +1839,6 @@ if (yamlOutput) yamlOutput.innerHTML = tintYaml(buildYaml(state)); } - // Append a per-field detail paragraph beneath a row when the - // operator is in beginner mode (state.detailsVisible). The paragraph - // expands what the hint glosses — 2-3 sentences explaining what the - // value MEANS, not just what range to pick. - function appendDetail(rowEl, detailKey) { - if (state.detailsVisible && detailKey) { - rowEl.appendChild(el('p', { class: 'wizard-row-detail', text: tr(detailKey) })); - } - } - // QLoRA toggle (4-bit base model quantization) var qloraInput = el('input', { type: 'checkbox' }); qloraInput.checked = state.qlora; @@ -1857,7 +1854,7 @@ ]), el('span', { class: 'wizard-row-hint', text: tr('wizard.training.qlora.hint') }) ]); - appendDetail(qloraRow, 'wizard.training.qlora.detail'); + appendDetail(state, qloraRow, 'wizard.training.qlora.detail'); pane.appendChild(qloraRow); // PEFT method radio (Standard LoRA / DoRA / PiSSA / RSLoRA). @@ -1894,7 +1891,7 @@ }); methodRow.appendChild(methodGrid); methodRow.appendChild(el('span', { class: 'wizard-row-hint', text: tr('wizard.training.lora_method.hint') })); - appendDetail(methodRow, 'wizard.training.lora_method.detail'); + appendDetail(state, methodRow, 'wizard.training.lora_method.detail'); pane.appendChild(methodRow); } @@ -1916,7 +1913,7 @@ ]), el('span', { class: 'wizard-row-hint', text: tr('wizard.training.galore.hint') }) ]); - appendDetail(galoreRow, 'wizard.training.galore.detail'); + appendDetail(state, galoreRow, 'wizard.training.galore.detail'); pane.appendChild(galoreRow); // GaLore variant select — only when GaLore is enabled. @@ -1944,7 +1941,7 @@ galoreSelect, el('span', { class: 'wizard-row-hint', text: tr('wizard.training.galore_optim.hint') }) ]); - appendDetail(galoreOptimRow, 'wizard.training.galore_optim.detail'); + appendDetail(state, galoreOptimRow, 'wizard.training.galore_optim.detail'); pane.appendChild(galoreOptimRow); } @@ -1970,7 +1967,7 @@ el('div', { class: 'wizard-slider-row' }, [slider, valueOut]), el('span', { class: 'wizard-row-hint', text: tr(hintKey) }) ]); - appendDetail(row, detailKey); + appendDetail(state, row, detailKey); return row; } // LoRA rank / alpha — only meaningful when LoRA-style adapters @@ -2003,7 +2000,7 @@ epochsInput, el('span', { class: 'wizard-row-hint', text: tr('wizard.training.epochs.hint') }) ]); - appendDetail(epochsRow, 'wizard.training.epochs.detail'); + appendDetail(state, epochsRow, 'wizard.training.epochs.detail'); pane.appendChild(epochsRow); // Learning rate. Includes the schema default ``2e-5`` (review- @@ -2026,7 +2023,7 @@ lrSelect, el('span', { class: 'wizard-row-hint', text: tr('wizard.training.lr.hint') }) ]); - appendDetail(lrRow, 'wizard.training.lr.detail'); + appendDetail(state, lrRow, 'wizard.training.lr.detail'); pane.appendChild(lrRow); // Batch size @@ -2046,7 +2043,7 @@ batchSelect, el('span', { class: 'wizard-row-hint', text: tr('wizard.training.batch.hint') }) ]); - appendDetail(batchRow, 'wizard.training.batch.detail'); + appendDetail(state, batchRow, 'wizard.training.batch.detail'); pane.appendChild(batchRow); // Max sequence length (P3 / review-cycle 2). Was previously @@ -2119,7 +2116,7 @@ el('div', { class: 'wizard-slider-row' }, [slider, valueOut]), el('span', { class: 'wizard-row-hint', text: tr(hintKey) }) ]); - appendDetail(row, detailKey); + appendDetail(state, row, detailKey); return row; } @@ -2146,7 +2143,7 @@ input, el('span', { class: 'wizard-row-hint', text: tr(hintKey) }) ]); - appendDetail(row, detailKey); + appendDetail(state, row, detailKey); return row; } @@ -2188,7 +2185,7 @@ rewardInput, el('span', { class: 'wizard-row-hint', text: tr('wizard.training.grpo_reward_model.hint') }) ]); - appendDetail(rewardRow, 'wizard.training.grpo_reward_model.detail'); + appendDetail(state, rewardRow, 'wizard.training.grpo_reward_model.detail'); pane.appendChild(rewardRow); } } @@ -2408,7 +2405,7 @@ } } }); - appendDetail(row, detailKey); + appendDetail(state, row, detailKey); return row; } @@ -2450,7 +2447,7 @@ ]), el('span', { class: 'wizard-row-hint', text: tr('wizard.risk_assessment.vulnerable_groups.hint') }) ]); - appendDetail(vgRow, 'wizard.risk_assessment.vulnerable_groups.detail'); + appendDetail(state, vgRow, 'wizard.risk_assessment.vulnerable_groups.detail'); body.appendChild(vgRow); })); @@ -2477,7 +2474,7 @@ ]), el('span', { class: 'wizard-row-hint', text: tr('wizard.benchmark.enabled.hint') }) ]); - appendDetail(benchToggleRow, 'wizard.benchmark.enabled.detail'); + appendDetail(state, benchToggleRow, 'wizard.benchmark.enabled.detail'); body.appendChild(benchToggleRow); if (state.benchmarkEnabled) { @@ -2549,7 +2546,7 @@ ]), el('span', { class: 'wizard-row-hint', text: tr('wizard.judge.enabled.hint') }) ]); - appendDetail(judgeToggleRow, 'wizard.judge.enabled.detail'); + appendDetail(state, judgeToggleRow, 'wizard.judge.enabled.detail'); body.appendChild(judgeToggleRow); if (state.judgeEnabled) { @@ -2642,16 +2639,6 @@ if (yamlOutput) yamlOutput.innerHTML = tintYaml(buildYaml(state)); } - // Same per-row tutorial-paragraph helper as the other steps. - // Adds a wizard-row-detail block beneath the row when the operator - // is in beginner mode (state.detailsVisible). The detail block - // gives 2-3 sentences of plain-language context the hint glosses. - function appendDetail(rowEl, detailKey) { - if (state.detailsVisible && detailKey) { - rowEl.appendChild(el('p', { class: 'wizard-row-detail', text: tr(detailKey) })); - } - } - // Webhook (Slack/Teams/Discord) accordion. pane.appendChild(makeAccordion(state, persist, 'webhook', 'wizard.webhook.title', 'wizard.webhook.hint', function (body) { @@ -2674,7 +2661,7 @@ ]), el('span', { class: 'wizard-row-hint', text: tr('wizard.webhook.enabled.hint') }) ]); - appendDetail(enabledRow, 'wizard.webhook.enabled.detail'); + appendDetail(state, enabledRow, 'wizard.webhook.enabled.detail'); body.appendChild(enabledRow); if (!state.webhookEnabled) return; @@ -2710,7 +2697,7 @@ urlRowChildren.push(el('div', { class: 'wizard-error', role: 'alert', text: tr(webhookUrlErrKey) })); } var urlRow = el('div', { class: 'wizard-row' }, urlRowChildren); - appendDetail(urlRow, 'wizard.webhook.url.detail'); + appendDetail(state, urlRow, 'wizard.webhook.url.detail'); body.appendChild(urlRow); function makeWebhookToggle(field, labelKey) { @@ -2748,7 +2735,7 @@ el('label', { class: 'wizard-toggle' }, [monInput, monLabelEl]), el('span', { class: 'wizard-row-hint', text: tr('wizard.monitoring.enabled.hint') }) ]); - appendDetail(monRow, 'wizard.monitoring.enabled.detail'); + appendDetail(state, monRow, 'wizard.monitoring.enabled.detail'); body.appendChild(monRow); var endpointShown = !!state.monitoringEnabled; @@ -2857,7 +2844,7 @@ ]), el('span', { class: 'wizard-row-hint', text: tr('wizard.synthetic.enabled.hint') }) ]); - appendDetail(synRow, 'wizard.synthetic.enabled.detail'); + appendDetail(state, synRow, 'wizard.synthetic.enabled.detail'); body.appendChild(synRow); if (state.syntheticEnabled) { diff --git a/tests/site/wizard_harness.cjs b/tests/site/wizard_harness.cjs new file mode 100644 index 00000000..bf2f72ad --- /dev/null +++ b/tests/site/wizard_harness.cjs @@ -0,0 +1,93 @@ +'use strict'; + +// Shared harness for the browser wizard regressions (not a test file: the +// `node --test tests/site/*.test.cjs` glob does not collect it). +// +// It runs the shipped site/js/wizard.js in a VM against a minimal DOM shim +// and exposes the wizard's closure functions through a test-only seam, so the +// tests exercise the real renderers and YAML builder rather than a copy. + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const root = path.resolve(__dirname, '../..'); + +function element(tag) { + const node = { + tag, children: [], attrs: {}, events: {}, className: '', + appendChild(child) { this.children.push(child); child.parent = this; return child; }, + setAttribute(key, value) { this.attrs[key] = String(value); }, + getAttribute(key) { return Object.hasOwn(this.attrs, key) ? this.attrs[key] : null; }, + addEventListener(key, handler) { this.events[key] = handler; }, + remove() { this.parent.children = this.parent.children.filter(child => child !== this); }, + // The renderers only look rows up by class (`row.querySelector('.wizard-error')`). + querySelector(selector) { + assert.match(selector, /^\.[\w-]+$/, `the DOM shim only supports class selectors, got ${selector}`); + return descendants(this).find(child => hasClass(child, selector.slice(1))) || null; + } + }; + node.classList = { + add(value) { if (!hasClass(node, value)) node.className = `${node.className} ${value}`.trim(); }, + remove(value) { node.className = classes(node).filter(item => item !== value).join(' '); }, + contains(value) { return hasClass(node, value); }, + toggle(value, force) { + const on = force === undefined ? !hasClass(node, value) : !!force; + if (on) node.classList.add(value); else node.classList.remove(value); + return on; + } + }; + return node; +} + +function classes(node) { + return (node.className || '').split(' ').filter(Boolean); +} + +function hasClass(node, value) { + return classes(node).includes(value); +} + +function loadWizard() { + const document = { + readyState: 'loading', addEventListener() {}, querySelector() { return null; }, createElement: element, + createElementNS: (_, tag) => element(tag), createTextNode: text => ({ text }) + }; + const context = vm.createContext({ window: {}, document, URL }); + for (const file of ['wizard_defaults.js', 'translations.js']) { + vm.runInContext(fs.readFileSync(path.join(root, 'site/js', file), 'utf8'), context); + } + const marker = ' /* ── Boot ─'; + const source = fs.readFileSync(path.join(root, 'site/js/wizard.js'), 'utf8'); + assert.equal(source.split(marker).length, 2, 'test seam must identify the boot boundary exactly once'); + // Expose closure functions only in the VM; production has no test-only API. + vm.runInContext(source.replace(marker, + ' window.testWizard = {defaultState, buildYaml, validateStep, coerceForRisk, parseEnvRef, STEP_RENDERERS, STEPS};\n' + + marker), context); + return { ...context.window.testWizard, translations: context.window.ForgeLMTranslations }; +} + +function state(wizard, overrides = {}) { + return Object.assign(wizard.defaultState(), { model: 'org/model', datasetName: 'org/dataset' }, overrides); +} + +function descendants(node) { + return [node, ...(node.children || []).flatMap(descendants)]; +} + +// Visible text of a node: text nodes, `text:` attributes and nested children. +function textOf(node) { + if (Object.hasOwn(node, 'text')) return node.text; + return (node.textContent || '') + (node.children || []).map(textOf).join(''); +} + +// Render one step into a fresh pane, the way the controller's render() does. +function renderStep(wizard, value, stepId) { + const pane = element('div'); + wizard.coerceForRisk(value); + wizard.STEP_RENDERERS[stepId](pane, value, () => {}, () => {}); + return pane; +} + +module.exports = { root, element, loadWizard, state, descendants, textOf, renderStep }; diff --git a/tests/site/wizard_monitoring.test.cjs b/tests/site/wizard_monitoring.test.cjs index b7e866bb..1620725a 100644 --- a/tests/site/wizard_monitoring.test.cjs +++ b/tests/site/wizard_monitoring.test.cjs @@ -6,51 +6,11 @@ const path = require('node:path'); const { spawnSync } = require('node:child_process'); const { test } = require('node:test'); const vm = require('node:vm'); +const { root, element, loadWizard, state, descendants } = require('./wizard_harness.cjs'); -const root = path.resolve(__dirname, '../..'); const pythonBin = process.platform === 'win32' ? ['Scripts', 'python.exe'] : ['bin', 'python']; const venvPython = path.join(process.env.VIRTUAL_ENV || path.join(root, '.venv'), ...pythonBin); const python = process.env.FORGELM_TEST_PYTHON || (fs.existsSync(venvPython) ? venvPython : 'python3'); -function element(tag) { - const node = { - tag, children: [], attrs: {}, events: {}, - appendChild(child) { this.children.push(child); child.parent = this; }, - setAttribute(key, value) { this.attrs[key] = value; }, - addEventListener(key, handler) { this.events[key] = handler; }, - remove() { this.parent.children = this.parent.children.filter(child => child !== this); }, - querySelector(selector) { - return descendants(this).find(child => (child.className || '').split(' ').includes(selector.slice(1))) || null; - } - }; - node.classList = { - add() {}, toggle() {}, - remove(value) { node.className = (node.className || '').split(' ').filter(item => item !== value).join(' '); } - }; - return node; -} -function loadWizard() { - const document = { - readyState: 'loading', addEventListener() {}, querySelector() { return null; }, createElement: element, - createElementNS: (_, tag) => element(tag), createTextNode: text => ({ text }) - }; - const context = vm.createContext({ window: {}, document, URL }); - for (const file of ['wizard_defaults.js', 'translations.js']) { - vm.runInContext(fs.readFileSync(path.join(root, 'site/js', file), 'utf8'), context); - } - const marker = ' /* ── Boot ─'; - const source = fs.readFileSync(path.join(root, 'site/js/wizard.js'), 'utf8'); - assert.equal(source.split(marker).length, 2, 'test seam must identify the boot boundary exactly once'); - // Expose closure functions only in the VM; production has no test-only API. - vm.runInContext(source.replace(marker, - ' window.testWizard = {defaultState, buildYaml, validateStep, coerceForRisk, parseEnvRef, STEP_RENDERERS};\n' + marker), context); - return { ...context.window.testWizard, translations: context.window.ForgeLMTranslations }; -} -function state(wizard, overrides = {}) { - return Object.assign(wizard.defaultState(), { model: 'org/model', datasetName: 'org/dataset' }, overrides); -} -function descendants(node) { - return [node, ...(node.children || []).flatMap(descendants)]; -} for (const tier of ['minimal-risk', 'limited-risk', 'high-risk', 'unacceptable']) { test(`monitoring remains opt-in at ${tier}; empty endpoint is valid`, () => { diff --git a/tests/site/wizard_render.test.cjs b/tests/site/wizard_render.test.cjs new file mode 100644 index 00000000..6359547e --- /dev/null +++ b/tests/site/wizard_render.test.cjs @@ -0,0 +1,145 @@ +'use strict'; + +// Render smoke coverage for every wizard step (#879). +// +// The Compliance renderer called a helper that only three other renderers +// declared, so every visit threw a ReferenceError before the risk-assessment, +// benchmark and judge controls were built. Nothing rendered that step, so the +// crash shipped. These tests render every step through the real renderers. + +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const { loadWizard, state, descendants, textOf, renderStep } = require('./wizard_harness.cjs'); + +const TIERS = ['unknown', 'minimal-risk', 'limited-risk', 'high-risk', 'unacceptable']; + +// Every conditional branch a renderer can take: optional gates on and off, +// both detail modes, the custom-model and GaLore paths and all six trainers. +function scenarios() { + const out = []; + for (const riskClassification of TIERS) { + for (const detailsVisible of [true, false]) { + for (const on of [false, true]) { + out.push({ + riskClassification, detailsVisible, autoRevert: on, benchmarkEnabled: on, judgeEnabled: on, + webhookEnabled: on, monitoringEnabled: on, syntheticEnabled: on, galoreEnabled: on, + modelPreset: on ? 'custom' : 'llama3-8b', trainerType: on ? 'grpo' : 'sft' + }); + } + } + } + for (const trainerType of ['sft', 'dpo', 'orpo', 'simpo', 'kto', 'grpo']) out.push({ trainerType }); + return out; +} + +// Text nodes carry no classList; only elements can match a class. +function hasClass(node, value) { + return Boolean(node.classList && node.classList.contains(value)); +} + +function rows(pane) { + return descendants(pane).filter(node => node.tag === 'div' && hasClass(node, 'wizard-row')); +} + +// The row whose caption reads `labelKey`. Outside a browser `tr()` has no +// translation table, so every caption renders as its own i18n key. +function rowFor(pane, labelKey) { + const matches = rows(pane).filter(row => descendants(row).some(node => + (node.tag === 'label' || hasClass(node, 'wizard-toggle-label')) && textOf(node).startsWith(labelKey))); + assert.equal(matches.length, 1, `exactly one row is captioned ${labelKey}`); + return matches[0]; +} + +function controlIn(row) { + const controls = descendants(row).filter(node => ['input', 'select', 'textarea'].includes(node.tag)); + assert.equal(controls.length, 1, 'a row hosts exactly one control'); + return controls[0]; +} + +function detailIn(row) { + return descendants(row).filter(node => node.tag === 'p' && hasClass(node, 'wizard-row-detail')) + .map(node => node.textContent); +} + +test('every step renders in every tier, detail mode and gate combination', () => { + const wizard = loadWizard(); + for (const overrides of scenarios()) { + for (const step of wizard.STEPS) { + const value = state(wizard, overrides); + assert.doesNotThrow(() => renderStep(wizard, value, step.id), `${step.id} with ${JSON.stringify(overrides)}`); + } + } +}); + +for (const tier of TIERS) { + for (const detailsVisible of [true, false]) { + test(`compliance step renders its risk-assessment and evaluation controls at ${tier} (details ${detailsVisible ? 'on' : 'off'})`, () => { + const wizard = loadWizard(); + const value = state(wizard, { riskClassification: tier, detailsVisible, benchmarkEnabled: true, judgeEnabled: true }); + const pane = renderStep(wizard, value, 'compliance'); + const expected = [ + ['wizard.risk_assessment.intended_use.label', 'textarea', 'wizard.risk_assessment.intended_use.detail'], + ['wizard.risk_assessment.foreseeable_misuse.label', 'textarea', 'wizard.risk_assessment.foreseeable_misuse.detail'], + ['wizard.risk_assessment.mitigation_measures.label', 'textarea', 'wizard.risk_assessment.mitigation_measures.detail'], + ['wizard.risk_assessment.vulnerable_groups.label', 'input', 'wizard.risk_assessment.vulnerable_groups.detail'], + ['wizard.benchmark.enabled.label', 'input', 'wizard.benchmark.enabled.detail'], + ['wizard.benchmark.tasks.label', 'textarea', null], + ['wizard.benchmark.min_score.label', 'input', null], + ['wizard.judge.enabled.label', 'input', 'wizard.judge.enabled.detail'], + ['wizard.judge.model.label', 'input', null], + ['wizard.judge.api_key_env.label', 'input', null], + ['wizard.judge.min_score.label', 'input', null] + ]; + for (const [labelKey, tag, detailKey] of expected) { + const row = rowFor(pane, labelKey); + assert.equal(controlIn(row).tag, tag, labelKey); + assert.deepEqual(detailIn(row), detailsVisible && detailKey ? [detailKey] : [], `${labelKey} detail paragraph`); + } + }); + } +} + +test('compliance controls still write the operator\'s answers into the YAML', () => { + const wizard = loadWizard(); + const value = state(wizard, { riskClassification: 'high-risk', benchmarkEnabled: true, judgeEnabled: true }); + const pane = renderStep(wizard, value, 'compliance'); + const misuse = controlIn(rowFor(pane, 'wizard.risk_assessment.foreseeable_misuse.label')); + misuse.value = 'Prompted to give medical advice.'; + misuse.events.input(); + const tasks = controlIn(rowFor(pane, 'wizard.benchmark.tasks.label')); + tasks.value = 'arc_easy\nhellaswag'; + tasks.events.input(); + const yaml = wizard.buildYaml(value); + assert.match(yaml, /\n foreseeable_misuse:\n - "Prompted to give medical advice\."\n/); + assert.match(yaml, /\n tasks:\n - "arc_easy"\n - "hellaswag"\n/); +}); + +for (const tier of ['high-risk', 'unacceptable']) { + test(`a ${tier} configuration passes the compliance step once its rendered fields are filled`, () => { + const wizard = loadWizard(); + // Both gate inputs ship with defaults; blank them so all eight required answers start missing. + const value = state(wizard, { riskClassification: tier, benchmarkEnabled: true, judgeEnabled: true, + benchmarkTasks: '', judgeApiKeyEnv: '' }); + const pane = renderStep(wizard, value, 'compliance'); + const required = { + 'wizard.compliance.provider_name.label': 'Acme AI Ltd.', + 'wizard.compliance.system_name.label': 'Support assistant', + 'wizard.compliance.intended_purpose.label': 'Triage support tickets', + 'wizard.risk_assessment.intended_use.label': 'Answer product questions', + 'wizard.risk_assessment.foreseeable_misuse.label': 'Medical advice', + 'wizard.risk_assessment.mitigation_measures.label': 'Refusal policy', + 'wizard.benchmark.tasks.label': 'arc_easy', + 'wizard.judge.api_key_env.label': 'OPENAI_API_KEY' + }; + // Array.from: the VM returns arrays from another realm, which deepEqual rejects by prototype. + assert.deepEqual(Array.from(wizard.validateStep(value, 'compliance'), error => error.field).sort(), [ + 'benchmarkTasks', 'foreseeableMisuse', 'intendedPurpose', 'intendedUse', 'judgeApiKeyEnv', + 'mitigationMeasures', 'providerName', 'systemName']); + for (const [labelKey, answer] of Object.entries(required)) { + const control = controlIn(rowFor(pane, labelKey)); + control.value = answer; + control.events.input(); + } + assert.deepEqual(Array.from(wizard.validateStep(value, 'compliance'), error => error.field), []); + }); +} From d03d851b96c74c603f1ecaaf3a37aea6e11b6cd4 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 01:04:01 +0300 Subject: [PATCH 02/35] fix(site): name every wizard field by its visible caption Text, number, slider, select and textarea captions were sibling