diff --git a/README.md b/README.md index 619af18..ed014a4 100644 --- a/README.md +++ b/README.md @@ -595,7 +595,7 @@ The package ships `schemas/combinatrix.schema.json`, a JSON Schema (draft 2020-1 ] ``` -To have an editor check such files, map their names to the schema in its settings, for example VS Code's `json.schemas` with the `fileMatch` pattern `*.rules.json` and the `url` `./node_modules/combinatrix/schemas/combinatrix.schema.json`. To validate in code, import it through the package's `exports` (`combinatrix/schemas/combinatrix.schema.json`) and give it to a draft 2020-12 validator such as Ajv's `ajv/dist/2020`. To load a model file without a validator, give its parsed contents to `parseModel`, which needs no schema at runtime; `parseRules` loads a rules file for dimensions written in code. +A model file can name the schema itself with a `$schema` key, which `parseModel` and `isModelData` accept and ignore and an editor reads without any setting: `"$schema": "https://cdn.jsdelivr.net/npm/combinatrix@/schemas/combinatrix.schema.json"`, the `$id` of the version it was written against. The explorer writes it into every model it starts, and a list of rules has no place for one. To have an editor check files that do not name it, map their names to the schema in its settings, for example VS Code's `json.schemas` with the `fileMatch` pattern `*.rules.json` and the `url` `./node_modules/combinatrix/schemas/combinatrix.schema.json`. To validate in code, import it through the package's `exports` (`combinatrix/schemas/combinatrix.schema.json`) and give it to a draft 2020-12 validator such as Ajv's `ajv/dist/2020`. To load a model file without a validator, give its parsed contents to `parseModel`, which needs no schema at runtime; `parseRules` loads a rules file for dimensions written in code. The schema covers only what is data. Computed dimensions, `sampled` domains, `predicate` and every other constraint given through the constraint contract are functions and are not described, and unset is not a JSON value, so a dimension is unset only through `activeWhen`. It states the shape, not what depends on the whole model: that names are unique, that every condition names a dimension and a value the model has and uses only node kinds an assignment of dimensions can back are checked by `defineModel`, and `parseModel` and `parseRules` check them for a model file and a rules file. A test requires the schema and `isCondition`, `isRule`, `isModelData`, `parseModel`, `parseRules` and `defineModel` to accept and refuse the same data, and `pnpm build` regenerates the file from the Zod definitions in `scripts/`, which are a development dependency only. diff --git a/e2e/form.e2e.ts b/e2e/form.e2e.ts index 3a9da70..bb898b7 100644 --- a/e2e/form.e2e.ts +++ b/e2e/form.e2e.ts @@ -5,10 +5,12 @@ import { EXAMPLES, exampleText } from '../site/src/examples'; import type { Json, JsonObject } from '../site/src/json'; import { getIn, isJsonArray, isJsonObject } from '../site/src/json'; import { EXPRESSION_KINDS, PREDICATE_KINDS } from '../site/src/nodes'; +import { SCHEMA_URL } from '../site/src/schema'; import { Explorer, inOrder, reference, RESULT_TIMEOUT_MS, textEquals, textLiteral, trackExternalRequests } from './explorer'; -/** The model the first test builds by hand, written as the form writes it. The rule excludes a combination on Windows or macOS that is not arm64. */ +/** The model the first test builds by hand, written as the form writes it, naming the schema it is written against. The rule excludes a combination on Windows or macOS that is not arm64. */ const BUILT_BY_HAND: JsonObject = { + $schema: SCHEMA_URL, dimensions: [ { kind: 'choice', name: 'os', values: ['linux', 'macos', 'windows'] }, { kind: 'choice', name: 'arch', values: ['x64', 'arm64'] }, @@ -246,6 +248,8 @@ test.describe('the form and the JSON view are one model', () => { await expect(explorer.cards('dimensions')).toHaveCount(dimensions.length); await expect(explorer.cards('rules')).toHaveCount(rules.length); await expect(explorer.page.locator('textarea.raw-json')).toHaveCount(0); + // The schema a model names is part of the model, not a key the form leaves unshown. + await expect(explorer.page.getByText('which the form does not show and keeps')).toHaveCount(0); await inOrder( dimensions.map((dimension, index) => ({ dimension, index })), async ({ dimension, index }) => { diff --git a/scripts/json-schema.ts b/scripts/json-schema.ts index 42ed1ca..8d91ddb 100644 --- a/scripts/json-schema.ts +++ b/scripts/json-schema.ts @@ -2,6 +2,7 @@ import { ExpressionNodeSchema, JsonValueSchema, PredicateNodeSchema } from "tril import { z } from "zod"; import pkg from "../package.json"; +import { schemaId } from "./schema-id"; /** * The Zod definitions of the library's serialisable data, from which `buildJsonSchema` derives the published JSON Schema. A condition is trilean's `PredicateNodeSchema` itself, so the schema states trilean trees exactly as trilean does. Zod is otherwise a development dependency used only for that derivation (trilean depends on it at runtime): the package validates at runtime with the guards in `src/guards.ts` and `src/shape.ts`, and `scripts/json-schema.unit.test.ts` proves the two accept and refuse the same data. `scripts/json-schema.types.unit.test.ts` proves at compile time that what each schema infers and the library type it describes accept the same data. @@ -81,6 +82,7 @@ export const FirstValidDimensionSchema = z /** The data form of a model: dimensions with literal domains, and rules. */ export const ModelSchema = z .strictObject({ + $schema: z.string().optional().describe("The URI of the JSON Schema the model is written against, which an editor reads to check the file. It has no effect on the model."), dimensions: z.array(z.union([ChoiceDimensionSchema, FirstValidDimensionSchema])).describe("The dimensions, in enumeration order: the first varies slowest."), rules: RulesSchema.optional().describe("Declarative constraints over the dimensions."), }) @@ -95,11 +97,11 @@ const DRAFT_2020_12 = "https://json-schema.org/draft/2020-12/schema"; const ROOT_DESCRIPTION = "The serialisable data of combinatrix: a model (choice and first-valid dimensions with literal values, activity conditions and rules), a list of rules, a single rule, or a single condition, which is a trilean predicate tree. Computed dimensions, sampled domains, predicates and other constraints given through the constraint contract are functions, not data, and are out of scope. A value that is unset is not a JSON value: a dimension is unset only through `activeWhen`."; -/** The published JSON Schema document, a draft 2020-12 schema whose `$id` names the package version it ships in. The `$id` is version-pinned, never `latest`, so a reference to it keeps meaning the schema it was written against. */ +/** The published JSON Schema document, a draft 2020-12 schema whose `$id` names the package version it ships in. */ export function buildJsonSchema(version: string): z.core.JSONSchema.JSONSchema { const root = z.union([ModelSchema, RulesSchema, RuleSchema, ConditionSchema]).meta({ title: "combinatrix", description: ROOT_DESCRIPTION }); - return { $schema: DRAFT_2020_12, $id: `https://cdn.jsdelivr.net/npm/combinatrix@${version}/schemas/combinatrix.schema.json`, ...z.toJSONSchema(root, { target: "draft-2020-12" }) }; + return { $schema: DRAFT_2020_12, $id: schemaId(version), ...z.toJSONSchema(root, { target: "draft-2020-12" }) }; } /** The version of the package this schema ships in. */ diff --git a/scripts/parse-model.unit.test.ts b/scripts/parse-model.unit.test.ts index a9b6a4c..778ac66 100644 --- a/scripts/parse-model.unit.test.ts +++ b/scripts/parse-model.unit.test.ts @@ -37,6 +37,19 @@ describe("isModelData", () => { expectAgreement(model, isModelData, accepts("Model")); }); + it("agrees with the JSON Schema on a model that declares a \"$schema\", whatever it holds", ONE_PROPERTY, () => { + const declared = fc.oneof(fc.string(), fc.jsonValue()); + const validModel = accepts("Model"); + fc.assert( + fc.property(model, declared, (data, schemaValue) => { + const written: unknown = { $schema: schemaValue, ...data }; + expect(isModelData(written), JSON.stringify(written)).toBe(validModel(written)); + expect(isModelData(written), JSON.stringify(written)).toBe(typeof schemaValue === "string"); + }), + { seed: SEED, numRuns: RUNS }, + ); + }); + it("accepts every value the schema's Model accepts, and every value it accepts the schema's root accepts", ONE_PROPERTY, () => { const validModel = accepts("Model"); const validRoot = accepts(undefined); @@ -91,6 +104,19 @@ describe("parseModel", () => { ); }); + it("builds the same model whether or not the data declares a \"$schema\"", ONE_PROPERTY, () => { + fc.assert( + fc.property(model, fc.string(), (data, schemaValue) => { + const plain = parseModel(data); + const declared = parseModel({ $schema: schemaValue, ...data }); + expect(declared.dimensions, JSON.stringify(data)).toEqual(plain.dimensions); + expect(declared.rules, JSON.stringify(data)).toEqual(plain.rules); + expect(collect(declared)).toEqual(collect(plain)); + }), + { seed: SEED, numRuns: RUNS }, + ); + }); + it("is refused with every issue for data of the wrong shape, as the guard refuses it", ONE_PROPERTY, () => { fc.assert( fc.property(nearMiss(model), ({ value }) => { diff --git a/scripts/schema-id.ts b/scripts/schema-id.ts new file mode 100644 index 0000000..4118eeb --- /dev/null +++ b/scripts/schema-id.ts @@ -0,0 +1,4 @@ +/** The `$id` of the published JSON Schema for a package version, which a model file names in its `$schema`. It is version-pinned, never `latest`, so a reference to it keeps meaning the schema it was written against. */ +export function schemaId(version: string): string { + return `https://cdn.jsdelivr.net/npm/combinatrix@${version}/schemas/combinatrix.schema.json`; +} diff --git a/site/src/editorState.unit.test.ts b/site/src/editorState.unit.test.ts index add293a..614c598 100644 --- a/site/src/editorState.unit.test.ts +++ b/site/src/editorState.unit.test.ts @@ -4,6 +4,7 @@ import { emptyState, nodeSwitch, stateAfterEdit, stateAfterSwitch, stateFromMode import type { JsonObject } from "./json"; import { NO_MEMORY, noSlots } from "./typeMemory"; import { convertJson } from "./modelEdit"; +import { SCHEMA_URL } from "./schema"; const PREVIOUS: JsonObject = { dimensions: [{ kind: "choice", name: "kept", values: ["a"] }] }; const BEFORE = stateFromModel(PREVIOUS); @@ -17,8 +18,12 @@ describe("stateFromModel", () => { expect(state.problem).toBeUndefined(); }); - it("starts empty with a model that has no dimensions", () => { - expect(emptyState().model).toEqual({ dimensions: [] }); + it("starts empty with a model that has no dimensions and names the schema it is written against", () => { + expect(emptyState().model).toEqual({ $schema: SCHEMA_URL, dimensions: [] }); + }); + + it("writes the schema first, where an editor looks for it", () => { + expect(emptyState().text).toBe(`{\n "$schema": ${JSON.stringify(SCHEMA_URL)},\n "dimensions": []\n}`); }); }); diff --git a/site/src/examples.ts b/site/src/examples.ts index 35c78f5..e435f23 100644 --- a/site/src/examples.ts +++ b/site/src/examples.ts @@ -1,60 +1,202 @@ +import { SCHEMA_URL } from "./schema"; + /** A model the page offers to load. */ export interface Example { readonly name: string; - /** The model as JSON data, the form `parseModel` accepts. */ + /** The model as JSON data, the form `parseModel` accepts, naming the schema it is written against. */ readonly model: unknown; } +/** An example whose model declares the schema it is written against, so an editor that opens an exported copy checks it. */ +function exampleOf(name: string, model: Readonly>): Example { + return { name, model: { $schema: SCHEMA_URL, ...model } }; +} + +/** The Node.js majors the examples that have a version dimension take, oldest to newest. */ +const OLDEST_NODE = 20; +const MIDDLE_NODE = 22; +const NEWEST_NODE = 24; + +/** A dimension's value, as a tree reads it. */ +function ref(key: string): unknown { + return { kind: "reference", key }; +} + +/** A text, a number and a boolean written into a tree. */ +function text(value: string): unknown { + return { kind: "textLiteral", value }; +} + +function num(value: number): unknown { + return { kind: "numberLiteral", value }; +} + +function bool(value: boolean): unknown { + return { kind: "booleanLiteral", value }; +} + /** The comparison a rule's tree makes of a dimension with a text: trilean's `textCompare`. */ function textIs(op: "equals" | "notEquals", key: string, value: string): unknown { - return { kind: "textCompare", op, left: { kind: "reference", key }, right: { kind: "textLiteral", value } }; + return { kind: "textCompare", op, left: ref(key), right: text(value) }; +} + +/** The comparison of a numeric dimension with a number: trilean's `compare`. */ +function numIs(op: "gt" | "gte" | "lt" | "lte" | "eq" | "neq", key: string, value: number): unknown { + return { kind: "compare", op, left: ref(key), right: num(value) }; +} + +/** The boolean dimension is true, or (with `false`) false: membership of its value in the one boolean, which is how the model states a flag. */ +function flagIs(key: string, value: boolean): unknown { + return { kind: "memberOf", op: "in", operand: ref(key), candidates: [bool(value)] }; +} + +/** The dimension's value is, or (`notIn`) is not, one of the given texts. */ +function textIn(op: "in" | "notIn", key: string, values: readonly string[]): unknown { + return { kind: "memberOf", op, operand: ref(key), candidates: values.map(text) }; +} + +/** Every condition holds, some condition holds, and the condition does not hold. */ +function allOf(...operands: readonly unknown[]): unknown { + return { kind: "allOf", operands }; +} + +function anyOf(...operands: readonly unknown[]): unknown { + return { kind: "anyOf", operands }; +} + +function not(operand: unknown): unknown { + return { kind: "not", operand }; +} + +/** The dimension is set: it is not left unset by its `activeWhen`. */ +function exists(key: string): unknown { + return { kind: "exists", operand: ref(key) }; } /** The dimension is set and `textIs`: an absent value compares as undecided, not false, so a dimension that can be unset is guarded by `exists`. */ function setAndIs(key: string, value: string): unknown { - return { kind: "and", left: { kind: "exists", operand: { kind: "reference", key } }, right: textIs("equals", key, value) }; + return { kind: "and", left: exists(key), right: textIs("equals", key, value) }; } -/** The models of the library's README, written as data. */ +/** + * The models the page offers, each showing a different part of what a model can state. The first three are the library README's, in the README's order, and the rest are ordered from the nearest to the furthest from them: rules that imply, numbers, values no rule leaves possible, membership and negation, an exception, a fallback, settings that exist only in a chain, and a space large enough for a covering array. + */ export const EXAMPLES: readonly Example[] = [ - { - name: "Operating systems and architectures", - model: { - dimensions: [ - { kind: "choice", name: "os", values: ["linux", "macos", "windows"] }, - { kind: "choice", name: "arch", values: ["x64", "arm64"] }, - ], - }, - }, - { - name: "A rule: bun is not offered on Windows", - model: { - dimensions: [ - { kind: "choice", name: "os", values: ["linux", "macos", "windows"] }, - { kind: "choice", name: "arch", values: ["x64", "arm64"] }, - { kind: "choice", name: "runtime", values: ["node", "bun", "deno"] }, - ], - rules: [{ name: "bun is not offered on windows", excludes: textIs("equals", "os", "windows"), when: textIs("equals", "runtime", "bun") }], - }, - }, - { - name: "A conditional, derived dimension", - model: { - dimensions: [ - { kind: "choice", name: "os", values: ["linux", "windows"] }, - { kind: "choice", name: "compress", values: [true, false] }, - { - kind: "first-valid", - name: "codec", - candidates: ["zstd", "brotli"], - activeWhen: { kind: "memberOf", op: "in", operand: { kind: "reference", key: "compress" }, candidates: [{ kind: "booleanLiteral", value: true }] }, - }, - ], - rules: [{ name: "windows has no zstd", excludes: setAndIs("codec", "zstd"), when: textIs("equals", "os", "windows") }], - }, - }, + exampleOf("Operating systems and architectures", { + dimensions: [ + { kind: "choice", name: "os", values: ["linux", "macos", "windows"] }, + { kind: "choice", name: "arch", values: ["x64", "arm64"] }, + ], + }), + exampleOf("A rule: bun is not offered on Windows", { + dimensions: [ + { kind: "choice", name: "os", values: ["linux", "macos", "windows"] }, + { kind: "choice", name: "arch", values: ["x64", "arm64"] }, + { kind: "choice", name: "runtime", values: ["node", "bun", "deno"] }, + ], + rules: [{ name: "bun is not offered on windows", excludes: textIs("equals", "os", "windows"), when: textIs("equals", "runtime", "bun") }], + }), + exampleOf("A conditional, derived dimension", { + dimensions: [ + { kind: "choice", name: "os", values: ["linux", "windows"] }, + { kind: "choice", name: "compress", values: [true, false] }, + { kind: "first-valid", name: "codec", candidates: ["zstd", "brotli"], activeWhen: flagIs("compress", true) }, + ], + rules: [{ name: "windows has no zstd", excludes: setAndIs("codec", "zstd"), when: textIs("equals", "os", "windows") }], + }), + exampleOf("Features that need other features", { + dimensions: [ + { kind: "choice", name: "tls", values: [true, false] }, + { kind: "choice", name: "http2", values: [true, false] }, + { kind: "choice", name: "http3", values: [true, false] }, + { kind: "choice", name: "compression", values: ["none", "gzip", "brotli"] }, + ], + rules: [ + { name: "http2 needs tls", implies: [flagIs("http2", true), flagIs("tls", true)] }, + { name: "http3 needs http2", implies: [flagIs("http3", true), flagIs("http2", true)] }, + { name: "brotli is only offered over tls", requires: flagIs("tls", true), when: textIs("equals", "compression", "brotli") }, + ], + }), + exampleOf("Numbers: a version floor", { + dimensions: [ + { kind: "choice", name: "os", values: ["linux", "macos", "windows"] }, + { kind: "choice", name: "arch", values: ["x64", "arm64"] }, + { kind: "choice", name: "node", values: [OLDEST_NODE, MIDDLE_NODE, NEWEST_NODE] }, + ], + rules: [{ name: "windows on arm64 needs node 22 or later", requires: numIs("gte", "node", MIDDLE_NODE), when: allOf(textIs("equals", "os", "windows"), textIs("equals", "arch", "arm64")) }], + }), + exampleOf("Values no rule leaves possible", { + dimensions: [ + { kind: "choice", name: "plan", values: ["free", "pro", "enterprise"] }, + { kind: "choice", name: "sso", values: [false, true] }, + { kind: "choice", name: "audit", values: [false, true] }, + ], + rules: [ + { name: "enterprise needs sso", requires: flagIs("sso", true), when: textIs("equals", "plan", "enterprise") }, + { name: "sso is not shipped yet", excludes: flagIs("sso", true) }, + ], + }), + exampleOf("A CI matrix: membership, any-of and not", { + dimensions: [ + { kind: "choice", name: "os", values: ["ubuntu", "macos", "windows"] }, + { kind: "choice", name: "node", values: [OLDEST_NODE, MIDDLE_NODE, NEWEST_NODE] }, + { kind: "choice", name: "browser", values: ["chromium", "firefox", "webkit"] }, + ], + rules: [ + { name: "webkit only runs where apple ships it", requires: textIn("in", "os", ["macos", "ubuntu"]), when: textIs("equals", "browser", "webkit") }, + { name: "windows runs only the newest node", excludes: anyOf(numIs("eq", "node", OLDEST_NODE), numIs("eq", "node", MIDDLE_NODE)), when: not(textIn("notIn", "os", ["windows"])) }, + ], + }), + exampleOf("A rule with an exception", { + dimensions: [ + { kind: "choice", name: "deploy", values: ["dev", "staging", "prod"] }, + { kind: "choice", name: "debug", values: [true, false] }, + { kind: "choice", name: "logLevel", values: ["debug", "info", "warn"] }, + ], + rules: [ + { name: "no debug flag in production", excludes: flagIs("debug", true), when: textIs("equals", "deploy", "prod") }, + { name: "debug logging is for development only", excludes: textIs("equals", "logLevel", "debug"), unless: textIs("equals", "deploy", "dev") }, + ], + }), + exampleOf("A derived dimension that falls back", { + dimensions: [ + { kind: "choice", name: "region", values: ["eu", "us"] }, + { kind: "choice", name: "tier", values: ["standard", "premium"] }, + { kind: "first-valid", name: "provider", candidates: ["aws", "gcp", "azure"] }, + ], + rules: [ + { name: "aws has no capacity in the us", excludes: textIs("equals", "provider", "aws"), when: textIs("equals", "region", "us") }, + { name: "aws is not offered at the premium tier", excludes: textIs("equals", "provider", "aws"), when: textIs("equals", "tier", "premium") }, + { name: "gcp has no eu region", excludes: textIs("equals", "provider", "gcp"), when: textIs("equals", "region", "eu") }, + ], + }), + exampleOf("Settings that exist only in a chain", { + dimensions: [ + { kind: "choice", name: "tls", values: [true, false] }, + { kind: "choice", name: "certificate", values: ["self-signed", "acme"], activeWhen: flagIs("tls", true) }, + { kind: "choice", name: "challenge", values: ["http-01", "dns-01"], activeWhen: setAndIs("certificate", "acme") }, + ], + }), + exampleOf("A larger space for covering arrays", { + dimensions: [ + { kind: "choice", name: "os", values: ["linux", "macos", "windows"] }, + { kind: "choice", name: "node", values: [OLDEST_NODE, MIDDLE_NODE, NEWEST_NODE] }, + { kind: "choice", name: "database", values: ["postgres", "mysql", "sqlite"] }, + { kind: "choice", name: "cache", values: ["none", "redis", "memcached"] }, + { kind: "choice", name: "browser", values: ["chromium", "firefox", "webkit"] }, + ], + rules: [{ name: "webkit is tested only on macos", requires: textIs("equals", "os", "macos"), when: textIs("equals", "browser", "webkit") }], + }), ]; +/** The example the page opens with when a link does not give a model: the first that has a rule. */ +export const STARTING_EXAMPLE: Example = (() => { + const [, started] = EXAMPLES; + if (started === undefined) throw new Error("unreachable: there are several examples"); + + return started; +})(); + /** The model of an example as the text the editor holds. */ export function exampleText(example: Example): string { return JSON.stringify(example.model, null, 2); diff --git a/site/src/examples.unit.test.ts b/site/src/examples.unit.test.ts index a893be1..e0a9a15 100644 --- a/site/src/examples.unit.test.ts +++ b/site/src/examples.unit.test.ts @@ -1,20 +1,92 @@ import { describe, expect, it } from "vitest"; import { execute } from "./engine"; -import { EXAMPLES, exampleText } from "./examples"; +import { EXAMPLES, exampleText, STARTING_EXAMPLE } from "./examples"; +import type { Request, Result } from "./protocol"; +import { isOp } from "./protocol"; +import { SCHEMA_URL } from "./schema"; -/** How many combinations each example's README section prints or states. */ -const PRODUCT_SIZE = 6; -const RULE_SIZE = 16; -const CONDITIONAL_SIZE = 4; -const README_COUNTS: readonly number[] = [PRODUCT_SIZE, RULE_SIZE, CONDITIONAL_SIZE]; +/** How many values the dimensions the examples share have. */ +const OPERATING_SYSTEMS = 3; +const ARCHITECTURES = 2; +const RUNTIMES = 3; +const NODE_VERSIONS = 3; +const COMPRESSIONS = 3; +const BROWSERS = 3; +const DATABASES = 3; +const CACHES = 3; +const REGIONS = 2; +const TIERS = 2; + +/** How many combinations the rules of an example remove, or leave. Bun on windows removes one for each architecture, and webkit on windows one for each node version. */ +const WINDOWS_ARM64_NODE_20 = 1; +const FEATURE_CHAIN = 4; +const BROTLI_WITHOUT_TLS = 1; +const WINDOWS_WITH_OLD_NODE = (NODE_VERSIONS - 1) * BROWSERS; +const WEBKIT_ON_WINDOWS_WITH_OLD_NODE = NODE_VERSIONS - 1; +const FREE_AND_PRO = 2; +const AUDIT_SETTINGS = 2; +const CONDITIONAL_COMBINATIONS = 4; +const CHAIN_COMBINATIONS = 4; +const DEBUG_FLAGS = 2; +const LOG_LEVELS = 3; +const QUIET_LOG_LEVELS = LOG_LEVELS - 1; +const DEV_COMBINATIONS = DEBUG_FLAGS * LOG_LEVELS; +const STAGING_COMBINATIONS = DEBUG_FLAGS * QUIET_LOG_LEVELS; +const LARGE_SPACE = OPERATING_SYSTEMS * NODE_VERSIONS * DATABASES * CACHES * BROWSERS; +const WEBKIT_ELSEWHERE = (OPERATING_SYSTEMS - 1) * NODE_VERSIONS * DATABASES * CACHES; + +/** + * The number of combinations each example has, with the arithmetic that gives it from the dimensions and the rules, so it is checked against the engine and not copied from it. The first three are the sizes the library's README states. + */ +const EXPECTED: ReadonlyMap = new Map([ + ["Operating systems and architectures", OPERATING_SYSTEMS * ARCHITECTURES], + ["A rule: bun is not offered on Windows", OPERATING_SYSTEMS * ARCHITECTURES * RUNTIMES - ARCHITECTURES], + // linux and windows, each compressed or not; the codec is unset where it is not compressed, and windows has only brotli. + ["A conditional, derived dimension", CONDITIONAL_COMBINATIONS], + // tls, http2 and http3 form a chain (http3 needs http2 needs tls), which leaves FEATURE_CHAIN of the eight: all three, the first two, tls alone, none. Each has every compression, less brotli without tls, which only the combination with all three off has. + ["Features that need other features", FEATURE_CHAIN * COMPRESSIONS - BROTLI_WITHOUT_TLS], + ["Numbers: a version floor", OPERATING_SYSTEMS * ARCHITECTURES * NODE_VERSIONS - WINDOWS_ARM64_NODE_20], + // sso is never on, so enterprise has no combination; free and pro have either audit setting. + ["Values no rule leaves possible", FREE_AND_PRO * AUDIT_SETTINGS], + // Webkit on windows, and windows with the two older nodes, share webkit on windows with those nodes. + ["A CI matrix: membership, any-of and not", OPERATING_SYSTEMS * NODE_VERSIONS * BROWSERS - (NODE_VERSIONS + WINDOWS_WITH_OLD_NODE - WEBKIT_ON_WINDOWS_WITH_OLD_NODE)], + // dev has every debug flag and log level; staging every flag and the quiet levels; prod no debug flag and the quiet levels. + ["A rule with an exception", DEV_COMBINATIONS + STAGING_COMBINATIONS + QUIET_LOG_LEVELS], + // region and tier choose the provider, which is derived: one combination for each. + ["A derived dimension that falls back", REGIONS * TIERS], + // tls off: one. tls on: a self-signed certificate: one; an acme certificate: both challenges. + ["Settings that exist only in a chain", CHAIN_COMBINATIONS], + // Every combination of the five dimensions, less webkit on the other operating systems with the other three dimensions free. + ["A larger space for covering arrays", LARGE_SPACE - WEBKIT_ELSEWHERE], +]); + +function succeed(request: Request): Result { + const response = execute(request); + if (!response.ok) throw new Error(`expected success, got ${JSON.stringify(response.failure)}`); + + return response.result; +} + +function textOf(name: string): string { + const example = EXAMPLES.find((candidate) => candidate.name === name); + if (example === undefined) throw new Error(`there is no example named ${name}`); + + return exampleText(example); +} describe("EXAMPLES", () => { - it("has one expected count per example", () => { - expect(EXAMPLES).toHaveLength(README_COUNTS.length); + it("has one expected count for each, under a name no other has", () => { + expect(new Set(EXAMPLES.map((example) => example.name)).size).toBe(EXAMPLES.length); + expect(EXAMPLES.map((example) => example.name)).toEqual([...EXPECTED.keys()]); }); - it.each(EXAMPLES.map((example, index) => ({ example, expected: README_COUNTS[index] })))("loads $example.name as a valid model with the size the README gives", ({ example, expected }) => { + it("starts with an example that has a rule", () => { + expect(EXAMPLES).toContain(STARTING_EXAMPLE); + expect(JSON.stringify(STARTING_EXAMPLE.model)).toContain('"rules"'); + }); + + it.each(EXAMPLES.map((example) => ({ example, expected: EXPECTED.get(example.name) })))("loads $example.name as a valid model with the size its arithmetic gives", ({ example, expected }) => { const response = execute({ op: "count", model: exampleText(example) }); expect(response).toEqual({ ok: true, result: { op: "count", count: expected } }); @@ -23,4 +95,63 @@ describe("EXAMPLES", () => { it("writes each example as JSON that parses back to its model", () => { for (const example of EXAMPLES) expect(JSON.parse(exampleText(example))).toEqual(example.model); }); + + it("names the JSON Schema each example is written against, first, as an editor expects", () => { + for (const example of EXAMPLES) expect(exampleText(example).startsWith(`{\n "$schema": ${JSON.stringify(SCHEMA_URL)},\n`), example.name).toBe(true); + }); + + it("covers every kind of dimension, every rule form and both rule scopes between them", () => { + const all = EXAMPLES.map(exampleText).join("\n"); + + for (const fragment of ['"kind": "choice"', '"kind": "first-valid"', '"activeWhen"', '"requires"', '"excludes"', '"implies"', '"when"', '"unless"', '"kind": "numberLiteral"', '"kind": "memberOf"', '"kind": "anyOf"', '"kind": "allOf"', '"kind": "not"']) { + expect(all, fragment).toContain(fragment); + } + }); + + it("leaves a plan and a flag in no combination in the example about values no rule leaves possible", () => { + const result = succeed({ op: "diagnose", model: textOf("Values no rule leaves possible") }); + if (!isOp(result, "diagnose")) throw new Error("expected a diagnosis"); + + expect(result.diagnosis).toEqual({ + status: "satisfiable", + count: 4, + deadValues: [ + { dimension: "plan", value: "enterprise" }, + { dimension: "sso", value: true }, + ], + }); + }); + + it("derives the first provider no rule rejects for each region and tier", () => { + const result = succeed({ op: "list", model: textOf("A derived dimension that falls back"), limit: 10 }); + if (!isOp(result, "list")) throw new Error("expected a list"); + + expect(result.rows.map((row) => row.values)).toEqual([ + { region: "eu", tier: "standard", provider: "aws" }, + { region: "eu", tier: "premium", provider: "azure" }, + { region: "us", tier: "standard", provider: "gcp" }, + { region: "us", tier: "premium", provider: "gcp" }, + ]); + }); + + it("leaves a setting unset where the setting it depends on does not hold", () => { + const result = succeed({ op: "list", model: textOf("Settings that exist only in a chain"), limit: 10 }); + if (!isOp(result, "list")) throw new Error("expected a list"); + + expect(result.rows.map((row) => row.values)).toEqual([ + { tls: true, certificate: "self-signed", challenge: undefined }, + { tls: true, certificate: "acme", challenge: "http-01" }, + { tls: true, certificate: "acme", challenge: "dns-01" }, + { tls: false, certificate: undefined, challenge: undefined }, + ]); + }); + + it("needs far fewer rows than combinations to cover every pair in the larger space", () => { + const model = textOf("A larger space for covering arrays"); + const result = succeed({ op: "covering", model, strength: 2 }); + if (!isOp(result, "covering")) throw new Error("expected a covering array"); + + expect(result.verification.status).toBe("covering"); + expect(result.rows.length).toBeLessThan(EXPECTED.get("A larger space for covering arrays") ?? 0); + }); }); diff --git a/site/src/main.ts b/site/src/main.ts index ed5293c..9c1901c 100644 --- a/site/src/main.ts +++ b/site/src/main.ts @@ -6,7 +6,7 @@ import type { SpawnWorker } from "./client"; import { CancelledError, EngineClient } from "./client"; import type { EditorState, ItemSwitch } from "./editorState"; import { emptyState, stateAfterEdit, stateAfterSwitch, stateFromModel, stateFromText } from "./editorState"; -import { EXAMPLES, exampleText } from "./examples"; +import { EXAMPLES, exampleText, STARTING_EXAMPLE } from "./examples"; import { unreachable } from "./exhaustive"; import type { SharedState } from "./fragment"; import { shareUrl } from "./fragment"; @@ -27,9 +27,6 @@ import { SavedModels } from "./saved"; /** How long after the last keystroke the editor's text is checked, so typing does not start a check per key. */ const VALIDATE_DEBOUNCE_MS = 250; -/** The example the editor starts with when the location asks for nothing else: the one that shows a rule. */ -const STARTING_EXAMPLE = 1; - /** The file Export writes. */ const EXPORT_FILE_NAME = "combinatrix-model.json"; @@ -649,10 +646,7 @@ function start(): void { void loadFromLocation().then((restored) => { if (restored) return; - const example = EXAMPLES[STARTING_EXAMPLE]; - if (example === undefined) throw new Error("unreachable: the starting example is one of the examples"); - - setModel(exampleText(example)); + setModel(exampleText(STARTING_EXAMPLE)); markOpened(); }); } diff --git a/site/src/modelEdit.ts b/site/src/modelEdit.ts index abc7a3f..91f193c 100644 --- a/site/src/modelEdit.ts +++ b/site/src/modelEdit.ts @@ -6,6 +6,7 @@ import { addEntry, arrayAt, getIn, insertAt, isJsonArray, isJsonObject, moveAt, import type { FoldCombiner, PredicateKind, RawView } from "./nodes"; import { classifyExpression, classifyPredicate, createPredicate } from "./nodes"; import { convertScalar, isScalar } from "./scalars"; +import { SCHEMA_URL } from "./schema"; import type { Slots } from "./typeMemory"; /** @@ -15,6 +16,9 @@ import type { Slots } from "./typeMemory"; /** The kinds of dimension the model's data form has: read from the library's `ModelData`, so a kind it gains is a compile error wherever a kind is listed below. */ export type DimensionKind = ModelData["dimensions"][number]["kind"]; +/** The top-level keys the library's model data defines, one entry for each so that a key it gains is a compile error here. */ +const MODEL_KEYS: readonly string[] = keysOf({ $schema: true, dimensions: true, rules: true }); + export const DIMENSION_KIND_LABELS: Readonly> = { choice: "free choice", "first-valid": "first valid candidate", @@ -64,7 +68,7 @@ export interface ModelView { readonly dimensions: readonly (DimensionView | RawView)[]; /** `undefined` when the model has no `rules` key. */ readonly rules: readonly (RuleView | RawView)[] | undefined; - /** The top-level keys the form does not show, kept as they are. */ + /** The top-level keys the library's model data does not define, which the form does not show and keeps as they are. */ readonly otherKeys: readonly string[]; } @@ -137,7 +141,7 @@ export function viewOfModel(model: JsonObject): ModelView { return { dimensions: dimensions.map(classifyDimension), rules: rules?.map(classifyRule), - otherKeys: Object.keys(model).filter((key) => key !== "dimensions" && key !== "rules"), + otherKeys: Object.keys(model).filter((key) => !MODEL_KEYS.includes(key)), }; } @@ -509,4 +513,4 @@ export function newPredicate(model: JsonObject, kind: PredicateKind): Json { } /** The model that a new editor starts from. */ -export const EMPTY_MODEL: JsonObject = { dimensions: [] }; +export const EMPTY_MODEL: JsonObject = { $schema: SCHEMA_URL, dimensions: [] }; diff --git a/site/src/modelEdit.unit.test.ts b/site/src/modelEdit.unit.test.ts index 56f77ea..0a6b798 100644 --- a/site/src/modelEdit.unit.test.ts +++ b/site/src/modelEdit.unit.test.ts @@ -68,6 +68,10 @@ describe("viewOfModel", () => { expect(view.otherKeys).toEqual(["note"]); }); + it("does not list the schema a model names among the keys it does not show", () => { + expect(viewOfModel({ $schema: "https://example.com/combinatrix.schema.json", dimensions: [] }).otherKeys).toEqual([]); + }); + it("shows a dimension of a kind the library does not take as data as raw", () => { const computed: Json = { kind: "computed", name: "target", reads: ["os"] }; @@ -116,7 +120,7 @@ describe("dimensions", () => { }); it("adds a first-valid dimension with its candidates under the right key", () => { - expect(addDimension(EMPTY_MODEL, "first-valid")).toEqual({ dimensions: [{ kind: "first-valid", name: "dimension", candidates: ["value"] }] }); + expect(addDimension(EMPTY_MODEL, "first-valid")).toEqual({ ...EMPTY_MODEL, dimensions: [{ kind: "first-valid", name: "dimension", candidates: ["value"] }] }); }); it("removes and moves a dimension", () => { diff --git a/site/src/schema.ts b/site/src/schema.ts new file mode 100644 index 0000000..1fb6a05 --- /dev/null +++ b/site/src/schema.ts @@ -0,0 +1,5 @@ +import pkg from "../../package.json" with { type: "json" }; +import { schemaId } from "../../scripts/schema-id"; + +/** The JSON Schema a model the page writes names in its `$schema`: the one published with the release this page's package is at, so an editor that opens an exported file checks it against the schema that release shipped. */ +export const SCHEMA_URL: string = schemaId(pkg.version); diff --git a/src/guards.ts b/src/guards.ts index 7419860..a3b0026 100644 --- a/src/guards.ts +++ b/src/guards.ts @@ -37,6 +37,8 @@ function fitsDimensions>>(rule: Rule, b * The data form of a model: what a JSON file holds and the JSON Schema describes. Its dimensions are choice or first-valid dimensions over literal values, and a dimension is unset only through `activeWhen`, since JSON has no `undefined`. Computed dimensions, `sampled` domains and constraints given through the constraint contract are functions and are not data. */ export interface ModelData { + /** The URI of the JSON Schema the model is written against, which an editor reads to check the file. It has no effect on the model. */ + readonly $schema?: string; /** The dimensions, in enumeration order: the first varies slowest. */ readonly dimensions: readonly (ChoiceDimension | FirstValidDimension)[]; /** Declarative constraints over the dimensions. */ @@ -44,11 +46,15 @@ export interface ModelData { } /** - * Every way a value fails to be well-formed model data, which is exactly the shape the published JSON Schema states for a model: a definition with only `dimensions` and `rules`; choice and first-valid dimensions only, each with a non-empty name and a non-empty list of distinct JSON scalars (never `undefined`, which JSON cannot carry); well-formed conditions and rules. Names, values and references that depend on the whole model are not shape and are checked by `defineModel`, which `parseModel` calls. + * Every way a value fails to be well-formed model data, which is exactly the shape the published JSON Schema states for a model: a definition with only `dimensions` and `rules`, and optionally a `$schema` string naming the JSON Schema it is written against; choice and first-valid dimensions only, each with a non-empty name and a non-empty list of distinct JSON scalars (never `undefined`, which JSON cannot carry); well-formed conditions and rules. Names, values and references that depend on the whole model are not shape and are checked by `defineModel`, which `parseModel` calls. */ function modelDataIssues(data: unknown): string[] { - const issues = modelShapeIssues(data); - if (!isRecord(data)) return issues; + if (!isRecord(data)) return modelShapeIssues(data); + + // `$schema` belongs to the file, not to the definition `defineModel` takes, so the definition's own check never sees it. + const { $schema: declared, ...definition } = data; + const issues = modelShapeIssues(definition); + if (Object.hasOwn(data, "$schema") && typeof declared !== "string") issues.push('the model data has a "$schema" that is not a string'); if (Object.hasOwn(data, "constraints")) issues.push("the model data has constraints, which are functions and not data"); const { dimensions } = data; diff --git a/src/guards.unit.test.ts b/src/guards.unit.test.ts index f98cd70..f321327 100644 --- a/src/guards.unit.test.ts +++ b/src/guards.unit.test.ts @@ -92,6 +92,10 @@ describe("isModelData", () => { expect(isModelData({ dimensions: [] })).toBe(true); }); + it("accepts a \"$schema\" string, which names the schema the file is written against", () => { + expect(isModelData({ $schema: "https://example.com/combinatrix.schema.json", dimensions: [] })).toBe(true); + }); + it("narrows to the data type", () => { const parsed: unknown = JSON.parse(MODEL_JSON); if (!isModelData(parsed)) throw new Error("the fixture is model data"); @@ -105,6 +109,8 @@ describe("isModelData", () => { { dimensions: {} }, { dimensions: [], rules: {} }, { dimensions: [], extra: true }, + { $schema: 1, dimensions: [] }, + { $schema: null, dimensions: [] }, { dimensions: [], constraints: [] }, { dimensions: [{ kind: "choice", name: "a", values: [] }] }, { dimensions: [{ kind: "choice", name: "", values: ["x"] }] },