135 lines
8.8 KiB
TypeScript
135 lines
8.8 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import test from 'node:test';
|
|
import { BUILTIN_THEMES, MAX_CSS_CHARS, MAX_IMAGE_CHARS, sanitizeSpec } from '../src/inject/themeSpec.ts';
|
|
import { resolvedTheme, X_FONT_STACK, THEME_DEFAULT } from '../src/inject/theme.ts';
|
|
import { themeVarsCss } from '../../../waybetter/packages/runtime/src/themeApi.ts';
|
|
|
|
type Schema = Record<string, any>;
|
|
const SCHEMA: Schema = JSON.parse(readFileSync(new URL('../../../waybetter/packages/waybetter/theme.schema.json', import.meta.url), 'utf8'));
|
|
|
|
/** The subset of JSON Schema (2020-12) the theme schema uses; returns the paths that fail. */
|
|
function validate(schema: Schema, value: unknown, path = '$', errors: string[] = []): string[] {
|
|
if (schema.$ref) return validate(SCHEMA.$defs[String(schema.$ref).replace('#/$defs/', '')], value, path, errors);
|
|
if (schema.oneOf) {
|
|
const passing = schema.oneOf.filter((option: Schema) => validate(option, value, path, []).length === 0).length;
|
|
if (passing !== 1) errors.push(`${path}: matches ${passing} of oneOf`);
|
|
return errors;
|
|
}
|
|
if (schema.anyOf) {
|
|
if (!schema.anyOf.some((option: Schema) => validate(option, value, path, []).length === 0)) errors.push(`${path}: matches no anyOf`);
|
|
return errors;
|
|
}
|
|
if ('const' in schema && value !== schema.const) errors.push(`${path}: not ${JSON.stringify(schema.const)}`);
|
|
if (schema.enum && !schema.enum.includes(value)) errors.push(`${path}: ${JSON.stringify(value)} not in enum`);
|
|
const type = schema.type;
|
|
const actual = value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value;
|
|
if (type === 'integer' ? !Number.isInteger(value) : type && type !== (actual === 'number' ? 'number' : actual)) {
|
|
errors.push(`${path}: expected ${type}, got ${actual}`);
|
|
return errors;
|
|
}
|
|
if (typeof value === 'number') {
|
|
if (schema.minimum !== undefined && value < schema.minimum) errors.push(`${path}: < ${schema.minimum}`);
|
|
if (schema.maximum !== undefined && value > schema.maximum) errors.push(`${path}: > ${schema.maximum}`);
|
|
}
|
|
if (typeof value === 'string') {
|
|
if (schema.maxLength !== undefined && value.length > schema.maxLength) errors.push(`${path}: longer than ${schema.maxLength}`);
|
|
if (schema.minLength !== undefined && value.length < schema.minLength) errors.push(`${path}: shorter than ${schema.minLength}`);
|
|
if (schema.pattern && !new RegExp(schema.pattern).test(value)) errors.push(`${path}: does not match ${schema.pattern}`);
|
|
}
|
|
if (Array.isArray(value)) {
|
|
if (schema.maxItems !== undefined && value.length > schema.maxItems) errors.push(`${path}: more than ${schema.maxItems} items`);
|
|
value.forEach((item, i) => schema.items && validate(schema.items, item, `${path}[${i}]`, errors));
|
|
}
|
|
if (actual === 'object') {
|
|
const object = value as Record<string, unknown>;
|
|
for (const key of schema.required ?? []) if (!(key in object)) errors.push(`${path}.${key}: required`);
|
|
for (const [key, item] of Object.entries(object)) {
|
|
const sub = schema.properties?.[key];
|
|
if (sub) validate(sub, item, `${path}.${key}`, errors);
|
|
else if (schema.additionalProperties === false) errors.push(`${path}.${key}: not allowed`);
|
|
}
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
test('every built-in theme, sanitized, is valid against theme.schema.json', () => {
|
|
for (const theme of BUILTIN_THEMES) {
|
|
assert.deepEqual(validate(SCHEMA, theme), [], theme.id);
|
|
assert.deepEqual(validate(SCHEMA, sanitizeSpec(JSON.parse(JSON.stringify(theme)))), [], `${theme.id} round trip`);
|
|
}
|
|
assert.deepEqual(validate(SCHEMA, { $schema: SCHEMA.$id, name: 'Minimal', colors: { bg: '#0b1110', text: '#e8f3ef', accent: '#3ddc97' } }), []);
|
|
assert.notDeepEqual(validate(SCHEMA, { name: 'Bad', colors: { bg: 'red', text: '#fff', accent: '#000000' } }), [], 'short/named colors are invalid');
|
|
assert.notDeepEqual(validate(SCHEMA, { name: 'Bad', background: { kind: 'image', url: 'https://example.com/a.png' } }), [], 'remote images are invalid');
|
|
assert.notDeepEqual(validate(SCHEMA, { name: 'Bad', layout: { column: 1200 } }), []);
|
|
assert.notDeepEqual(validate(SCHEMA, { description: 'no name' }), []);
|
|
});
|
|
|
|
test('schema limits are the sanitizer limits: numbers clamp to the same bounds, enums match', () => {
|
|
const groups = ['font', 'shape', 'layout', 'effects'] as const;
|
|
for (const group of groups) {
|
|
for (const [key, spec] of Object.entries(SCHEMA.properties[group].properties as Record<string, Schema>)) {
|
|
const range = spec.anyOf ? spec.anyOf.find((option: Schema) => option.minimum !== undefined) : spec;
|
|
if (range?.minimum !== undefined) {
|
|
const low = sanitizeSpec({ name: 't', [group]: { [key]: range.minimum - 1000 } }) as unknown as Record<string, Record<string, number>>;
|
|
const high = sanitizeSpec({ name: 't', [group]: { [key]: range.maximum + 1000 } }) as unknown as Record<string, Record<string, number>>;
|
|
assert.equal(low[group]![key], range.minimum, `${group}.${key} minimum`);
|
|
assert.equal(high[group]![key], range.maximum, `${group}.${key} maximum`);
|
|
const fallback = sanitizeSpec({ name: 't' }) as unknown as Record<string, Record<string, number>>;
|
|
assert.equal(fallback[group]![key], spec.default, `${group}.${key} default`);
|
|
}
|
|
if (spec.enum) {
|
|
for (const option of spec.enum) {
|
|
const out = sanitizeSpec({ name: 't', [group]: { [key]: option } }) as unknown as Record<string, Record<string, unknown>>;
|
|
assert.equal(out[group]![key], option, `${group}.${key} keeps ${option}`);
|
|
}
|
|
const out = sanitizeSpec({ name: 't', [group]: { [key]: 'nope' } }) as unknown as Record<string, Record<string, unknown>>;
|
|
assert.equal(out[group]![key], spec.default, `${group}.${key} unknown falls back`);
|
|
}
|
|
}
|
|
}
|
|
assert.deepEqual(SCHEMA.properties.mode.enum, ['light', 'dark', 'auto']);
|
|
assert.equal(sanitizeSpec({ name: 't', mode: 'sepia' }).mode, 'auto');
|
|
assert.equal(SCHEMA.properties.css.maxLength, MAX_CSS_CHARS);
|
|
assert.equal(sanitizeSpec({ name: 't', css: 'x'.repeat(MAX_CSS_CHARS + 5) }).css.length, MAX_CSS_CHARS);
|
|
const [, gradient, pattern, image] = SCHEMA.properties.background.oneOf as Schema[];
|
|
assert.equal(image!.properties.url.maxLength, MAX_IMAGE_CHARS);
|
|
const bg = (value: unknown) => sanitizeSpec({ name: 't', background: value }).background as Record<string, unknown>;
|
|
assert.equal(bg({ kind: 'gradient', from: '#000000', to: '#ffffff', angle: 999 }).angle, gradient!.properties.angle.maximum);
|
|
assert.equal(bg({ kind: 'pattern', ink: '#000000', opacity: 5, size: 1 }).opacity, pattern!.properties.opacity.maximum);
|
|
assert.equal(bg({ kind: 'pattern', ink: '#000000', opacity: 5, size: 1 }).size, pattern!.properties.size.minimum);
|
|
assert.deepEqual(pattern!.properties.pattern.enum, ['dots', 'grid', 'diagonal', 'scanlines', 'paper', 'clouds']);
|
|
for (const kind of pattern!.properties.pattern.enum) assert.equal(bg({ kind: 'pattern', ink: '#000000', pattern: kind }).pattern, kind);
|
|
assert.equal(bg({ kind: 'image', url: 'data:image/png;base64,AAAA', dim: 0 }).dim, image!.properties.dim.minimum);
|
|
assert.equal(bg({ kind: 'image', url: 'https://example.com/a.png' }).kind, 'none');
|
|
assert.equal(sanitizeSpec({ name: 'x'.repeat(80) }).name.length, SCHEMA.properties.name.maxLength);
|
|
assert.equal(sanitizeSpec({ name: 't', description: 'x'.repeat(300) }).description.length, SCHEMA.properties.description.maxLength);
|
|
});
|
|
|
|
test('resolved theme: X default follows X palette and accent, themes give their own roles', () => {
|
|
const light = resolvedTheme(THEME_DEFAULT, 'light');
|
|
assert.equal(light.siteDefault, true);
|
|
assert.equal(light.mode, 'light');
|
|
assert.equal(light.colors.bg, '#ffffff');
|
|
assert.equal(light.colors.text, '#0f1419');
|
|
assert.equal(light.colors.accent, '#1d9bf0');
|
|
assert.equal(light.colors.inverse, '#0f1419');
|
|
assert.equal(light.font, X_FONT_STACK);
|
|
assert.equal(light.radius, 16);
|
|
const dark = resolvedTheme(THEME_DEFAULT, 'dark');
|
|
assert.equal(dark.mode, 'dark');
|
|
assert.equal(dark.colors.bg, '#000000');
|
|
assert.equal(dark.colors.inverse, '#eff3f4');
|
|
assert.equal(resolvedTheme({ ...THEME_DEFAULT, accent: '#f91880' }, 'dark').colors.accent, '#f91880', 'accent override on X default');
|
|
|
|
const paper = resolvedTheme({ ...THEME_DEFAULT, preset: 'paper' }, 'dark');
|
|
const spec = BUILTIN_THEMES.find(theme => theme.id === 'paper')!;
|
|
assert.equal(paper.siteDefault, false);
|
|
assert.equal(paper.mode, 'light', 'mode comes from the theme colors, not X');
|
|
assert.equal(paper.colors.bg, spec.colors!.bg);
|
|
assert.equal(paper.radius, spec.shape.radius);
|
|
assert.match(paper.font, new RegExp(spec.font.family[0]!.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
|
|
const night = resolvedTheme({ ...THEME_DEFAULT, preset: 'night-ink' }, 'light');
|
|
assert.equal(night.mode, 'dark');
|
|
assert.match(themeVarsCss(night), /^:root\{--wb-bg:#[0-9a-f]{6};/);
|
|
});
|