chore: import upstream snapshot with attribution
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:08:55 +08:00
commit 7a0da7932b
2985 changed files with 1049377 additions and 0 deletions
+288
View File
@@ -0,0 +1,288 @@
/**
* Tests for utils/form-validation.js
*
* Tests the FormValidator class and built-in validator functions
* used for inline form validation with ARIA support.
*/
import '@js/utils/form-validation.js';
const FormValidator = window.FormValidator;
const validators = window.formValidators;
describe('FormValidator', () => {
let form;
let input;
let validator;
beforeEach(() => {
form = document.createElement('form');
input = document.createElement('input');
input.type = 'text';
input.id = 'test-field';
form.appendChild(input);
document.body.appendChild(form);
validator = new FormValidator();
});
afterEach(() => {
document.body.removeChild(form);
});
describe('addValidation', () => {
it('creates an error element next to the field', () => {
validator.addValidation(input, () => null);
const errorEl = document.getElementById('test-field-error');
expect(errorEl).not.toBeNull();
expect(errorEl.className).toBe('ldr-field-error');
});
it('sets aria-describedby on the field', () => {
validator.addValidation(input, () => null);
expect(input.getAttribute('aria-describedby')).toContain('test-field-error');
});
it('sets aria-live on error element', () => {
validator.addValidation(input, () => null);
const errorEl = document.getElementById('test-field-error');
expect(errorEl.getAttribute('aria-live')).toBe('polite');
});
it('accepts selector string instead of element', () => {
validator.addValidation('#test-field', () => null);
const errorEl = document.getElementById('test-field-error');
expect(errorEl).not.toBeNull();
});
it('does nothing for non-existent selector', () => {
expect(() => {
validator.addValidation('#nonexistent', () => null);
}).not.toThrow();
});
it('generates an id for fields without one', () => {
const noIdInput = document.createElement('input');
noIdInput.type = 'text';
form.appendChild(noIdInput);
validator.addValidation(noIdInput, () => null);
expect(noIdInput.id).toMatch(/^form-field-/);
});
});
describe('validateField', () => {
it('returns true when validator returns null (valid)', () => {
validator.addValidation(input, () => null);
expect(validator.validateField(input)).toBe(true);
});
it('returns false when validator returns error message', () => {
validator.addValidation(input, () => 'Error!');
expect(validator.validateField(input)).toBe(false);
});
it('adds ldr-field-invalid class on error', () => {
validator.addValidation(input, () => 'Error!');
validator.validateField(input);
expect(input.classList.contains('ldr-field-invalid')).toBe(true);
});
it('sets aria-invalid on error', () => {
validator.addValidation(input, () => 'Error!');
validator.validateField(input);
expect(input.getAttribute('aria-invalid')).toBe('true');
});
it('displays error message in error element', () => {
validator.addValidation(input, () => 'Field is required');
validator.validateField(input);
const errorEl = document.getElementById('test-field-error');
expect(errorEl.textContent).toBe('Field is required');
expect(errorEl.style.display).toBe('block');
});
it('clears error state when field becomes valid', () => {
validator.addValidation(input, (val) => (val ? null : 'Required'));
input.value = '';
validator.validateField(input);
expect(input.classList.contains('ldr-field-invalid')).toBe(true);
input.value = 'filled';
validator.validateField(input);
expect(input.classList.contains('ldr-field-invalid')).toBe(false);
expect(input.hasAttribute('aria-invalid')).toBe(false);
});
it('returns true for unregistered elements', () => {
const unknownInput = document.createElement('input');
expect(validator.validateField(unknownInput)).toBe(true);
});
});
describe('validateAll', () => {
it('returns true when all fields are valid', () => {
const input2 = document.createElement('input');
input2.id = 'field-2';
form.appendChild(input2);
validator.addValidation(input, () => null);
validator.addValidation(input2, () => null);
expect(validator.validateAll()).toBe(true);
});
it('returns false when any field is invalid', () => {
const input2 = document.createElement('input');
input2.id = 'field-2';
form.appendChild(input2);
validator.addValidation(input, () => null);
validator.addValidation(input2, () => 'Error');
expect(validator.validateAll()).toBe(false);
});
it('validates all fields (not short-circuit)', () => {
const input2 = document.createElement('input');
input2.id = 'field-2';
form.appendChild(input2);
validator.addValidation(input, () => 'Error 1');
validator.addValidation(input2, () => 'Error 2');
validator.validateAll();
// Both should show errors
expect(input.classList.contains('ldr-field-invalid')).toBe(true);
expect(input2.classList.contains('ldr-field-invalid')).toBe(true);
});
});
describe('clearErrors', () => {
it('removes error classes and messages from all fields', () => {
validator.addValidation(input, () => 'Error');
validator.validateField(input);
expect(input.classList.contains('ldr-field-invalid')).toBe(true);
validator.clearErrors();
expect(input.classList.contains('ldr-field-invalid')).toBe(false);
expect(input.hasAttribute('aria-invalid')).toBe(false);
const errorEl = document.getElementById('test-field-error');
expect(errorEl.textContent).toBe('');
expect(errorEl.style.display).toBe('none');
});
});
describe('showError', () => {
it('shows custom error on a field', () => {
validator.addValidation(input, () => null);
validator.showError(input, 'Custom error');
expect(input.classList.contains('ldr-field-invalid')).toBe(true);
expect(input.getAttribute('aria-invalid')).toBe('true');
const errorEl = document.getElementById('test-field-error');
expect(errorEl.textContent).toBe('Custom error');
});
it('accepts selector string', () => {
validator.addValidation(input, () => null);
validator.showError('#test-field', 'Custom error');
expect(input.classList.contains('ldr-field-invalid')).toBe(true);
});
it('does nothing for non-existent selector', () => {
expect(() => {
validator.showError('#nonexistent', 'Error');
}).not.toThrow();
});
});
});
describe('Built-in validators', () => {
describe('required', () => {
const required = validators.required();
it('returns null for non-empty value', () => {
expect(required('hello')).toBeNull();
});
it('returns error for empty string', () => {
expect(required('')).toBe('This field is required');
});
it('returns error for whitespace-only string', () => {
expect(required(' ')).toBe('This field is required');
});
it('returns error for null/undefined', () => {
expect(required(null)).toBe('This field is required');
expect(required(undefined)).toBe('This field is required');
});
it('accepts custom message', () => {
const custom = validators.required('Please fill this in');
expect(custom('')).toBe('Please fill this in');
});
});
describe('minLength', () => {
const minLen = validators.minLength(3);
it('returns null for values meeting minimum', () => {
expect(minLen('abc')).toBeNull();
expect(minLen('abcd')).toBeNull();
});
it('returns error for short values', () => {
expect(minLen('ab')).toBe('Must be at least 3 characters');
});
it('returns null for empty value (let required handle it)', () => {
expect(minLen('')).toBeNull();
expect(minLen(null)).toBeNull();
});
it('accepts custom message', () => {
const custom = validators.minLength(5, 'Too short!');
expect(custom('abc')).toBe('Too short!');
});
});
describe('maxLength', () => {
const maxLen = validators.maxLength(5);
it('returns null for values within limit', () => {
expect(maxLen('abc')).toBeNull();
expect(maxLen('abcde')).toBeNull();
});
it('returns error for values exceeding limit', () => {
expect(maxLen('abcdef')).toBe('Must be no more than 5 characters');
});
it('returns null for empty value', () => {
expect(maxLen('')).toBeNull();
expect(maxLen(null)).toBeNull();
});
});
describe('pattern', () => {
const emailLike = validators.pattern(/^[^@]+@[^@]+$/, 'Invalid email');
it('returns null for matching values', () => {
expect(emailLike('user@example.com')).toBeNull();
});
it('returns error for non-matching values', () => {
expect(emailLike('not-an-email')).toBe('Invalid email');
});
it('returns null for empty value', () => {
expect(emailLike('')).toBeNull();
expect(emailLike(null)).toBeNull();
});
it('uses default message when none provided', () => {
const digits = validators.pattern(/^\d+$/);
expect(digits('abc')).toBe('Invalid format');
});
});
});
+59
View File
@@ -0,0 +1,59 @@
/**
* Tests for utils/format-bytes.js — window.formatBytes.
*
* Single source of truth for human-readable byte sizes, shared by
* delete_manager.js and pdf_upload_handler.js. The unit-boundary
* rounding (1023 -> Bytes, 1024 -> KB) is the interesting part.
*/
import '@js/utils/format-bytes.js';
const formatBytes = window.formatBytes;
describe('formatBytes', () => {
it('returns "0 Bytes" for 0', () => {
expect(formatBytes(0)).toBe('0 Bytes');
});
it('formats sub-kilobyte values in Bytes', () => {
expect(formatBytes(500)).toBe('500 Bytes');
expect(formatBytes(1023)).toContain('Bytes');
});
it('formats kilobytes, including fractional', () => {
expect(formatBytes(1024)).toBe('1 KB');
expect(formatBytes(1536)).toBe('1.5 KB');
});
it('formats megabytes and gigabytes', () => {
expect(formatBytes(1048576)).toBe('1 MB');
expect(formatBytes(1073741824)).toBe('1 GB');
});
it('rounds to 2 decimal places', () => {
// 1234567 bytes ≈ 1.18 MB
expect(formatBytes(1234567)).toBe('1.18 MB');
});
it('picks the unit by power-of-1024 boundary (1023->Bytes, 1024->KB)', () => {
expect(formatBytes(1023)).toContain('Bytes');
expect(formatBytes(1024)).toContain('KB');
expect(formatBytes(1024 * 1024 - 1)).toContain('KB');
expect(formatBytes(1024 * 1024)).toContain('MB');
});
it('formats terabyte-and-larger sizes without "undefined"', () => {
// Regression: sizes only went up to GB, so i>=4 yielded "N undefined".
expect(formatBytes(1024 ** 4)).toBe('1 TB');
expect(formatBytes(5 * 1024 ** 4)).toBe('5 TB');
expect(formatBytes(1024 ** 5)).toBe('1 PB');
expect(formatBytes(1024 ** 6)).toBe('1 EB');
// Beyond the largest unit, clamp to EB rather than emit undefined.
expect(formatBytes(1024 ** 7)).toBe('1024 EB');
});
it('handles sub-1-byte values without a negative index (lower-bound clamp)', () => {
// log(bytes) < 0 for bytes < 1 would give a negative index -> undefined.
expect(formatBytes(0.5)).toBe('0.5 Bytes');
});
});
+210
View File
@@ -0,0 +1,210 @@
/**
* Tests for utils/log-helpers.js
*
* The log panel filters and dedups thousands of log entries during a
* research run. These helpers power that — an off-by-one in
* checkLogVisibility means warnings disappear; a collision in
* hashString means dedupe drops real entries.
*/
import '@js/utils/log-helpers.js';
const {
checkLogVisibility,
emptyCounts,
hashString,
normalizeMessage,
normalizeTimestamps,
} = window.LdrLogHelpers;
describe('checkLogVisibility', () => {
it("filter 'all' shows every type", () => {
for (const type of ['info', 'milestone', 'warning', 'error']) {
expect(checkLogVisibility(type, 'all')).toBe(true);
}
});
it("filter 'info' only shows info entries (regression: was a no-op)", () => {
// The pre-fix implementation returned true for every type under
// the 'info' filter, which made clicking the Info filter button
// effectively a no-op. This test pins down the correct, narrow
// behaviour: only entries whose type is 'info' are visible.
expect(checkLogVisibility('info', 'info')).toBe(true);
expect(checkLogVisibility('milestone', 'info')).toBe(false);
expect(checkLogVisibility('warning', 'info')).toBe(false);
expect(checkLogVisibility('error', 'info')).toBe(false);
});
it("filter 'milestone' only shows milestones", () => {
expect(checkLogVisibility('milestone', 'milestone')).toBe(true);
expect(checkLogVisibility('info', 'milestone')).toBe(false);
expect(checkLogVisibility('warning', 'milestone')).toBe(false);
expect(checkLogVisibility('error', 'milestone')).toBe(false);
});
it("accepts plural 'milestones' as an alias", () => {
expect(checkLogVisibility('milestone', 'milestones')).toBe(true);
expect(checkLogVisibility('info', 'milestones')).toBe(false);
});
it("filter 'warning' shows warnings and errors", () => {
expect(checkLogVisibility('warning', 'warning')).toBe(true);
expect(checkLogVisibility('error', 'warning')).toBe(true);
expect(checkLogVisibility('info', 'warning')).toBe(false);
expect(checkLogVisibility('milestone', 'warning')).toBe(false);
});
it("accepts plural 'warnings' as an alias", () => {
expect(checkLogVisibility('warning', 'warnings')).toBe(true);
expect(checkLogVisibility('error', 'warnings')).toBe(true);
expect(checkLogVisibility('info', 'warnings')).toBe(false);
});
it("filter 'error' only shows errors", () => {
expect(checkLogVisibility('error', 'error')).toBe(true);
expect(checkLogVisibility('warning', 'error')).toBe(false);
expect(checkLogVisibility('info', 'error')).toBe(false);
expect(checkLogVisibility('milestone', 'error')).toBe(false);
});
it("accepts plural 'errors' as an alias", () => {
expect(checkLogVisibility('error', 'errors')).toBe(true);
expect(checkLogVisibility('warning', 'errors')).toBe(false);
});
it('unknown filter defaults to showing everything', () => {
expect(checkLogVisibility('info', 'bogus')).toBe(true);
expect(checkLogVisibility('error', '')).toBe(true);
expect(checkLogVisibility('milestone', undefined)).toBe(true);
});
});
describe('emptyCounts', () => {
it('returns a fresh object on each call (mutations do not leak)', () => {
const a = emptyCounts();
const b = emptyCounts();
expect(a).not.toBe(b);
a.info = 99;
expect(b.info).toBe(0);
});
it('initialises every category to zero with the expected keys', () => {
const counts = emptyCounts();
expect(counts).toEqual({
info: 0,
milestone: 0,
warning: 0,
error: 0,
});
});
});
describe('hashString', () => {
it('returns "0" for empty/null/undefined', () => {
expect(hashString('')).toBe('0');
expect(hashString(null)).toBe('0');
expect(hashString(undefined)).toBe('0');
});
it('is deterministic: same input → same output', () => {
const a = hashString('log entry one');
const b = hashString('log entry one');
expect(a).toBe(b);
});
it('produces different hashes for different inputs', () => {
expect(hashString('log entry one')).not.toBe(hashString('log entry two'));
expect(hashString('a')).not.toBe(hashString('b'));
});
it('handles long strings', () => {
const long = 'x'.repeat(10000);
expect(hashString(long)).toMatch(/^-?\d+$/);
});
it('handles unicode characters', () => {
// Should not throw and should differ from its ASCII analogue
expect(hashString('café')).toMatch(/^-?\d+$/);
expect(hashString('café')).not.toBe(hashString('cafe'));
});
});
describe('normalizeMessage', () => {
it('returns empty string for falsy input', () => {
expect(normalizeMessage('')).toBe('');
expect(normalizeMessage(null)).toBe('');
expect(normalizeMessage(undefined)).toBe('');
});
it('trims whitespace', () => {
expect(normalizeMessage(' hello ')).toBe('hello');
expect(normalizeMessage('\t\nhello\r\n')).toBe('hello');
});
it('lowercases', () => {
expect(normalizeMessage('HELLO World')).toBe('hello world');
});
it('trims and lowercases in combination', () => {
expect(normalizeMessage(' HELLO ')).toBe('hello');
});
});
describe('normalizeTimestamps', () => {
it('is a no-op when all logs share the same date', () => {
const logs = [
{time: '2026-04-01T10:00:00Z', message: 'a'},
{time: '2026-04-01T11:00:00Z', message: 'b'},
{time: '2026-04-01T12:00:00Z', message: 'c'},
];
const before = JSON.parse(JSON.stringify(logs));
normalizeTimestamps(logs);
expect(logs).toEqual(before);
});
it('re-stamps an outlier date to the majority date', () => {
const logs = [
{time: '2026-04-01T10:00:00.000Z', message: 'a'},
{time: '2026-04-01T11:00:00.000Z', message: 'b'},
{time: '2026-04-01T12:00:00.000Z', message: 'c'},
{time: '2026-04-02T15:30:00.000Z', message: 'outlier'},
];
const originalTime = logs[3].time;
normalizeTimestamps(logs);
const outlier = logs[3];
// The outlier's date portion should now be 2026-04-01
expect(outlier.time.startsWith('2026-04-01')).toBe(true);
// The time should have changed from the original
expect(outlier.time).not.toBe(originalTime);
// ID should be regenerated from the new timestamp + hash
expect(outlier.id).toMatch(/^2026-04-01.*-/);
});
it('does nothing when logs is empty', () => {
const logs = [];
expect(() => normalizeTimestamps(logs)).not.toThrow();
expect(logs).toEqual([]);
});
it('does nothing when every log has an unparseable time', () => {
const logs = [
{time: 'not-a-date', message: 'a'},
{time: 'also-not-a-date', message: 'b'},
];
// Should not throw; mostCommonDate would remain null
expect(() => normalizeTimestamps(logs)).not.toThrow();
});
it('handles a tie by picking one date deterministically (first seen wins via iteration order)', () => {
// Two days with equal count — either is acceptable, just shouldn't crash
const logs = [
{time: '2026-04-01T10:00:00Z', message: 'a'},
{time: '2026-04-02T10:00:00Z', message: 'b'},
];
expect(() => normalizeTimestamps(logs)).not.toThrow();
// Both should end up on the same day
const d1 = logs[0].time.split('T')[0];
const d2 = logs[1].time.split('T')[0];
expect(d1).toBe(d2);
});
});
+66
View File
@@ -0,0 +1,66 @@
/**
* Tests for utils/provider-options.js
*
* resolveProviderOptions backs the settings-page model-provider dropdown.
* It must prefer the backend's auto-discovered list so the dropdown can't
* drift from the provider registry (#4622), while falling back safely when
* the API list is unavailable (offline / fetch failure).
*/
import '@js/utils/provider-options.js';
const { resolveProviderOptions } = window.LdrProviderOptions;
const STATIC = [
{ value: 'OLLAMA', label: 'Ollama (Local)' },
{ value: 'OPENAI', label: 'OpenAI (Cloud)' },
];
describe('resolveProviderOptions', () => {
it('prefers the auto-discovered list when it has entries', () => {
const discovered = [
{ value: 'OPENAI', label: 'OpenAI ☁️ Cloud' },
{ value: 'XAI', label: 'xAI ☁️ Cloud' },
];
// Returned as-is (same reference) so the full discovered set is shown.
expect(resolveProviderOptions(discovered, [], STATIC)).toBe(discovered);
});
it('falls back to the llm.provider settings options when discovery is empty', () => {
const allSettings = [
{ key: 'something.else', options: [{ value: 'X', label: 'X' }] },
{
key: 'llm.provider',
options: [
{ value: 'GOOGLE', label: 'Google', extra: 'ignored' },
],
},
];
const result = resolveProviderOptions([], allSettings, STATIC);
// Projected to {value, label} only (drops arbitrary backend props).
expect(result).toEqual([{ value: 'GOOGLE', label: 'Google' }]);
});
it('falls back to the static list when neither discovery nor settings have options', () => {
expect(resolveProviderOptions([], [], STATIC)).toBe(STATIC);
expect(
resolveProviderOptions([], [{ key: 'llm.provider', options: [] }], STATIC),
).toBe(STATIC);
expect(
resolveProviderOptions([], [{ key: 'other', options: [{ value: 'a', label: 'a' }] }], STATIC),
).toBe(STATIC);
});
it('is defensive against non-array discovered / settings input', () => {
expect(resolveProviderOptions(undefined, undefined, STATIC)).toBe(STATIC);
expect(resolveProviderOptions(null, null, STATIC)).toBe(STATIC);
expect(resolveProviderOptions('nope', 42, STATIC)).toBe(STATIC);
});
it('does not mutate its inputs', () => {
const discovered = [{ value: 'A', label: 'A' }];
const snapshot = JSON.parse(JSON.stringify(discovered));
resolveProviderOptions(discovered, [], STATIC);
expect(discovered).toEqual(snapshot);
});
});
+164
View File
@@ -0,0 +1,164 @@
/**
* Tests for utils/value-helpers.js
*
* These helpers back the settings page's "did the user change this?"
* detection and its "old → new" change notifications. Broken equality
* rules cause settings to look unchanged (silent drops) or cause
* spurious save notifications — both surfaced as real bugs before.
*/
import '@js/utils/value-helpers.js';
const {
areValuesEqual,
areObjectsEqual,
formatPropertyName,
formatValueForDisplay,
} = window.LdrValueHelpers;
describe('areValuesEqual', () => {
it('treats null and undefined as interchangeable (symmetric)', () => {
expect(areValuesEqual(null, null)).toBe(true);
expect(areValuesEqual(undefined, undefined)).toBe(true);
expect(areValuesEqual(null, undefined)).toBe(true);
expect(areValuesEqual(undefined, null)).toBe(true);
});
it('returns false when only one side is null/undefined', () => {
expect(areValuesEqual(null, 0)).toBe(false);
expect(areValuesEqual(0, null)).toBe(false);
expect(areValuesEqual(undefined, '')).toBe(false);
expect(areValuesEqual('', undefined)).toBe(false);
expect(areValuesEqual(null, false)).toBe(false);
});
it('handles primitive equality', () => {
expect(areValuesEqual(1, 1)).toBe(true);
expect(areValuesEqual('a', 'a')).toBe(true);
expect(areValuesEqual(true, true)).toBe(true);
expect(areValuesEqual(1, 2)).toBe(false);
expect(areValuesEqual('a', 'b')).toBe(false);
});
it('coerces numeric strings to numbers for comparison', () => {
expect(areValuesEqual(1000, '1000')).toBe(true);
expect(areValuesEqual('1000', 1000)).toBe(true);
expect(areValuesEqual(1.5, '1.5')).toBe(true);
expect(areValuesEqual(1000, '1001')).toBe(false);
});
it('returns false for mismatched types other than number/string', () => {
expect(areValuesEqual(true, 1)).toBe(false);
expect(areValuesEqual(false, 0)).toBe(false);
expect(areValuesEqual({}, 'object')).toBe(false);
});
it('compares arrays by length then deep equality', () => {
expect(areValuesEqual([1, 2, 3], [1, 2, 3])).toBe(true);
expect(areValuesEqual([], [])).toBe(true);
expect(areValuesEqual([1, 2], [1, 2, 3])).toBe(false);
expect(areValuesEqual([1, 2, 3], [1, 3, 2])).toBe(false); // order matters
expect(areValuesEqual([{a: 1}], [{a: 1}])).toBe(true);
});
it('compares objects by JSON stringification', () => {
expect(areValuesEqual({a: 1, b: 2}, {a: 1, b: 2})).toBe(true);
expect(areValuesEqual({a: 1}, {a: 2})).toBe(false);
expect(areValuesEqual({}, {})).toBe(true);
});
it('does not confuse an array with an equivalent object', () => {
// [] stringifies as "[]" and {} as "{}", so these differ
expect(areValuesEqual([], {})).toBe(false);
});
});
describe('areObjectsEqual', () => {
it('treats empty objects as equal', () => {
expect(areObjectsEqual({}, {})).toBe(true);
});
it('returns false when key counts differ', () => {
expect(areObjectsEqual({a: 1}, {a: 1, b: 2})).toBe(false);
expect(areObjectsEqual({a: 1, b: 2}, {a: 1})).toBe(false);
});
it('returns false when a key is missing on the other side', () => {
expect(areObjectsEqual({a: 1}, {b: 1})).toBe(false);
});
it('delegates value comparison to areValuesEqual', () => {
// Numeric string coercion works via areValuesEqual
expect(areObjectsEqual({port: 8080}, {port: '8080'})).toBe(true);
});
it('recurses through nested objects via areValuesEqual', () => {
expect(areObjectsEqual(
{nested: {a: 1, b: [2, 3]}},
{nested: {a: 1, b: [2, 3]}}
)).toBe(true);
expect(areObjectsEqual(
{nested: {a: 1}},
{nested: {a: 2}}
)).toBe(false);
});
});
describe('formatPropertyName', () => {
it('converts snake_case to Title Case', () => {
expect(formatPropertyName('llm_provider')).toBe('Llm Provider');
expect(formatPropertyName('max_output_tokens')).toBe('Max Output Tokens');
});
it('handles a single word', () => {
expect(formatPropertyName('provider')).toBe('Provider');
});
it('handles an already-capitalized word', () => {
expect(formatPropertyName('Provider')).toBe('Provider');
});
});
describe('formatValueForDisplay', () => {
it('returns "empty" for null and undefined', () => {
expect(formatValueForDisplay(null)).toBe('empty');
expect(formatValueForDisplay(undefined)).toBe('empty');
});
it('returns "enabled"/"disabled" for booleans', () => {
expect(formatValueForDisplay(true)).toBe('enabled');
expect(formatValueForDisplay(false)).toBe('disabled');
});
it('wraps short strings in quotes', () => {
expect(formatValueForDisplay('hello')).toBe('"hello"');
expect(formatValueForDisplay('')).toBe('""');
});
it('truncates strings longer than 20 chars', () => {
const long = 'a'.repeat(25);
const result = formatValueForDisplay(long);
expect(result).toBe('"' + 'a'.repeat(18) + '..."');
expect(result.length).toBeLessThan(long.length + 2);
});
it('keeps 20-char strings as-is (boundary)', () => {
const twenty = 'a'.repeat(20);
expect(formatValueForDisplay(twenty)).toBe(`"${twenty}"`);
});
it('truncates 21-char strings (off-by-one boundary)', () => {
const twentyOne = 'a'.repeat(21);
expect(formatValueForDisplay(twentyOne)).toBe('"' + 'a'.repeat(18) + '..."');
});
it('shows objects as "{...}"', () => {
expect(formatValueForDisplay({a: 1})).toBe('{...}');
});
it('coerces numbers to their string form', () => {
expect(formatValueForDisplay(42)).toBe('42');
expect(formatValueForDisplay(0)).toBe('0');
expect(formatValueForDisplay(3.14)).toBe('3.14');
});
});
+114
View File
@@ -0,0 +1,114 @@
/**
* Tests for utils/yaml_export.js — the YAML serialization helpers used by the
* benchmark export (yamlEscape / formatSettingValue / formatSettingsSnapshot).
*
* These are load-bearing for export correctness: a regression in yamlEscape
* would silently corrupt every downloaded benchmark YAML (and could let a
* model name / URL with YAML-special chars inject an unintended key). This
* pins the escaping behaviour now that the function is importable.
*/
import '@js/utils/yaml_export.js';
const { yamlEscape, formatSettingValue, formatSettingsSnapshot } = window;
describe('yamlEscape', () => {
it('returns empty string for null/undefined', () => {
expect(yamlEscape(null)).toBe('');
expect(yamlEscape(undefined)).toBe('');
});
it('leaves plain strings untouched', () => {
expect(yamlEscape('qwen3.6')).toBe('qwen3.6');
});
it('escapes backslash FIRST so later escapes are not double-escaped', () => {
// A lone backslash must become exactly two, not four.
expect(yamlEscape('a\\b')).toBe('a\\\\b');
// Backslash + quote: backslash doubled, quote escaped — order matters.
expect(yamlEscape('\\"')).toBe('\\\\\\"');
});
it('escapes double quotes, newlines, CR and tabs', () => {
expect(yamlEscape('he said "hi"')).toBe('he said \\"hi\\"');
expect(yamlEscape('a\nb')).toBe('a\\nb');
expect(yamlEscape('a\rb')).toBe('a\\rb');
expect(yamlEscape('a\tb')).toBe('a\\tb');
});
it('coerces non-strings via String()', () => {
expect(yamlEscape(42)).toBe('42');
});
});
describe('formatSettingValue', () => {
it('emits null for null/undefined', () => {
expect(formatSettingValue(null)).toBe('null');
expect(formatSettingValue(undefined)).toBe('null');
});
it('emits bare booleans and finite numbers', () => {
expect(formatSettingValue(true)).toBe('true');
expect(formatSettingValue(false)).toBe('false');
expect(formatSettingValue(128000)).toBe('128000');
});
it('emits null for non-finite numbers', () => {
expect(formatSettingValue(Infinity)).toBe('null');
expect(formatSettingValue(NaN)).toBe('null');
});
it('emits inline JSON for arrays and objects', () => {
expect(formatSettingValue([1, 'a'])).toBe('[1,"a"]');
expect(formatSettingValue({ a: 1 })).toBe('{"a":1}');
});
it('double-quotes and escapes strings', () => {
expect(formatSettingValue('gpt-4')).toBe('"gpt-4"');
// A value with a YAML-special char must stay a single safe scalar.
expect(formatSettingValue('a: b # c')).toBe('"a: b # c"');
expect(formatSettingValue('inject\nkey: pwned')).toBe(
'"inject\\nkey: pwned"',
);
});
});
describe('formatSettingsSnapshot', () => {
it('renders a sentinel comment for a null snapshot', () => {
expect(formatSettingsSnapshot(null)).toBe(
'settings: null # snapshot not captured for this run\n',
);
});
it('renders an empty flow map for {}', () => {
expect(formatSettingsSnapshot({})).toBe('settings: {}\n');
});
it('renders sorted keys with formatted values (metadata shape)', () => {
const out = formatSettingsSnapshot({
'llm.model': { value: 'qwen3.6', ui_element: 'select' },
'a.flag': { value: true, ui_element: 'checkbox' },
});
// a.flag sorts before llm.model; string quoted, bool bare.
expect(out).toBe(
'settings:\n a.flag: true\n llm.model: "qwen3.6"\n',
);
});
it('quotes keys that contain YAML-significant characters', () => {
const out = formatSettingsSnapshot({ 'has space': { value: 'x' } });
expect(out).toContain('"has space": "x"');
});
it('passes flat (non-metadata) scalar values through', () => {
const out = formatSettingsSnapshot({ 'llm.model': 'qwen3.6' });
expect(out).toBe('settings:\n llm.model: "qwen3.6"\n');
});
it('renders null for a metadata object missing its `value` key', () => {
const out = formatSettingsSnapshot({
'llm.model': { ui_element: 'select' },
});
expect(out).toBe('settings:\n llm.model: null\n');
});
});