From ef14f75b8e45e0fcfebf45969d0b56386fc58ad8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 21:35:17 +0000 Subject: [PATCH 1/2] test(dom-leak): one attribute judge, shared by both gates (#4434) The "is this attribute HTML-defined" judge existed in two copies, inline in two test files that cannot import each other, and the copies had already diverged. Extract it once into a new private, never-published `@object-ui/test-support` package, carrying the union of what both copies knew, with the calibration fixtures moved next to it so it proves itself once instead of once per copy. Only the judge unifies: each gate keeps its own canary sets, targets, readiness selectors, ledger and assertions. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 --- .changeset/config.json | 3 +- .changeset/shared-dom-leak-judge.md | 13 + packages/app-shell/package.json | 1 + .../__tests__/widget-dom-leak-sweep.test.tsx | 256 +++------------ packages/fields/package.json | 1 + .../__tests__/widget-dom-leak-e2e.test.tsx | 299 ++---------------- packages/test-support/README.md | 57 ++++ packages/test-support/package.json | 31 ++ .../src/__tests__/dom-leak-judge.test.tsx | 167 ++++++++++ packages/test-support/src/dom-leak-judge.ts | 259 +++++++++++++++ packages/test-support/src/index.ts | 31 ++ packages/test-support/tsconfig.json | 19 ++ pnpm-lock.yaml | 12 + vitest.config.mts | 5 + 14 files changed, 663 insertions(+), 491 deletions(-) create mode 100644 .changeset/shared-dom-leak-judge.md create mode 100644 packages/test-support/README.md create mode 100644 packages/test-support/package.json create mode 100644 packages/test-support/src/__tests__/dom-leak-judge.test.tsx create mode 100644 packages/test-support/src/dom-leak-judge.ts create mode 100644 packages/test-support/src/index.ts create mode 100644 packages/test-support/tsconfig.json diff --git a/.changeset/config.json b/.changeset/config.json index 156238e17f..f1d29d49fe 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -50,7 +50,8 @@ "updateInternalDependencies": "patch", "ignore": [ "@object-ui/example-*", - "@object-ui/site" + "@object-ui/site", + "@object-ui/test-support" ], "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { "onlyUpdatePeerDependentsWhenOutOfRange": true diff --git a/.changeset/shared-dom-leak-judge.md b/.changeset/shared-dom-leak-judge.md new file mode 100644 index 0000000000..1c213440ae --- /dev/null +++ b/.changeset/shared-dom-leak-judge.md @@ -0,0 +1,13 @@ +--- +--- + +Test-only (objectui#4434): the DOM-leak attribute judge — `isKnownAttribute`, +`findLeaks`, `leakReport`, the happy-dom IDL gap table, the SVG presentation +list and the open attribute families — is now one shared module in the new +`@object-ui/test-support` package, instead of two already-diverged copies +defined inline in the `@object-ui/fields` and `@object-ui/app-shell` DOM-leak +gates. Its calibration fixtures moved with it and prove it once, for both. + +Nothing published changes. `@object-ui/test-support` is `private: true` and is +never released; the two consumers gain a `devDependency` on it and no runtime +dependency, no `exports` map entry and no public API were added or altered. diff --git a/packages/app-shell/package.json b/packages/app-shell/package.json index 5703cce123..b0cd400853 100644 --- a/packages/app-shell/package.json +++ b/packages/app-shell/package.json @@ -88,6 +88,7 @@ "@object-ui/plugin-list": "workspace:*", "@object-ui/plugin-report": "workspace:*", "@object-ui/plugin-view": "workspace:*", + "@object-ui/test-support": "workspace:*", "@types/node": "^26.1.2", "@types/qrcode": "^1.5.6", "@types/react": "19.2.18", diff --git a/packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx b/packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx index 6cbe3235d8..8263ca1c84 100644 --- a/packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx +++ b/packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx @@ -148,16 +148,22 @@ * required" placeholder instead of the calendar. Renders go through the * provider, and each target carries the minimum schema its real markup needs. * - * ## The judge is duplicated, deliberately and temporarily - * - * `isKnownAttribute` / `findLeaks` below are a copy of the judge in - * `packages/fields/src/__tests__/widget-dom-leak-e2e.test.tsx`. That judge lives - * INSIDE a test file and is not exported, so reusing it would mean editing - * `packages/fields` — out of scope for a measurement-only PR, and its gate is - * the reference the card points at. Two copies of one judge is how one judge - * becomes two disagreeing judges, so the copy is recorded as a finding rather - * than left to be discovered: extracting it to a shared home is phase-2 work. - * The calibration fixtures below are what keep this copy honest in the meantime. + * ## The judge is shared — it is no longer this file's to keep + * + * `isKnownAttribute` / `findLeaks` / `leakReport` used to be defined below, as + * a copy of the judge in `packages/fields/src/__tests__/widget-dom-leak-e2e.test.tsx`. + * That copy is gone: objectui#4434 extracted one judge into + * `@object-ui/test-support` (private, never published — see its README for why + * that home and not a subpath export), carrying the UNION of what the two + * copies knew. The sweep-only half of that union is the ten recharts + * marker/gradient/pattern attributes this file's SVG list had grown and the + * fields copy never had; the calibration fixtures moved with the judge and now + * exercise them. + * + * What did NOT move is everything below: the canary sets, the target + * enumeration, the readiness selectors, {@link LEAK_LEDGER} and every + * assertion. Only the judge unifies — a judge that starts carrying one gate's + * policy is back to being two judges wearing one name. */ import type { ComponentType } from 'react'; @@ -180,154 +186,10 @@ import '@object-ui/plugin-charts'; import '@object-ui/plugin-calendar'; import '@object-ui/plugin-chatbot'; import '@object-ui/plugin-dashboard'; - -/* ════════════════════════════════════════════════════════════════════════════ - * The judge: is this attribute one HTML actually defines? - * (copy of the #3291 judge — see "The judge is duplicated" above) - * ══════════════════════════════════════════════════════════════════════════ */ - -/** Open families. `data-*` is the one open family the widget contract declares. */ -const OPEN_PREFIXES = [ - 'data-', - 'aria-', - // `cmdk-root` / `cmdk-input` / … are marks the cmdk library puts on ITS OWN - // DOM. Not prop pass-through. - 'cmdk-', -]; - -/** Global HTML attributes, legitimate on any element. */ -const GLOBAL_HTML_ATTRIBUTES = new Set([ - 'id', 'class', 'style', 'title', 'lang', 'dir', 'hidden', 'tabindex', 'role', - 'slot', 'part', 'exportparts', 'itemid', 'itemprop', 'itemref', 'itemscope', - 'itemtype', 'translate', 'draggable', 'spellcheck', 'autocapitalize', - 'autocorrect', 'contenteditable', 'enterkeyhint', 'inputmode', 'accesskey', - 'nonce', 'is', 'popover', 'inert', 'autofocus', -]); - -/** Attributes whose IDL property is spelled too differently to match by case. */ -const ATTRIBUTE_TO_IDL_ALIAS: Record = { - 'class': 'className', - 'for': 'htmlFor', - 'accept-charset': 'acceptCharset', - 'http-equiv': 'httpEquiv', -}; - -/** MEASURED gaps in happy-dom's IDL — standard attributes it does not reflect. */ -const HAPPY_DOM_IDL_GAPS: Record> = { - select: new Set(['size']), - option: new Set(['label']), - textarea: new Set(['wrap']), - col: new Set(['span']), - colgroup: new Set(['span']), -}; - -/** - * SVG needs its own list: the reflection trick does not hold for SVG under - * happy-dom, and lucide icons put a fixed set of presentation attributes on - * every icon. Recharts adds a few more of its own. - */ -const SVG_ATTRIBUTES = new Set([ - 'xmlns', 'xmlns:xlink', 'version', 'viewbox', 'preserveaspectratio', - 'width', 'height', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'cx', 'cy', 'r', 'rx', - 'ry', 'd', 'points', 'transform', 'fill', 'fill-rule', 'fill-opacity', - 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', - 'stroke-dasharray', 'stroke-dashoffset', 'stroke-opacity', 'opacity', - 'clip-path', 'clip-rule', 'mask', 'offset', 'stop-color', 'stop-opacity', - 'gradientunits', 'gradienttransform', 'patternunits', 'text-anchor', - 'dominant-baseline', 'font-size', 'font-family', 'font-weight', - 'vector-effect', 'shape-rendering', 'focusable', 'overflow', 'color', - 'orient', 'refx', 'refy', 'markerwidth', 'markerheight', 'markerunits', - 'patterncontentunits', 'spreadmethod', 'gradientscale', 'pathlength', -]); - -/** Lowercased IDL property names on a tag's prototype chain, cached per tag. */ -const idlCache = new Map>(); - -function idlPropertiesFor(tagName: string): Set { - const tag = tagName.toLowerCase(); - const cached = idlCache.get(tag); - if (cached) return cached; - - const names = new Set(); - const element = document.createElement(tag); - for (const own of Object.getOwnPropertyNames(element)) names.add(own.toLowerCase()); - for ( - let proto = Object.getPrototypeOf(element); - proto && proto !== Object.prototype; - proto = Object.getPrototypeOf(proto) - ) { - for (const name of Object.getOwnPropertyNames(proto)) names.add(name.toLowerCase()); - } - idlCache.set(tag, names); - return names; -} - -/** - * The rule: an attribute is legitimate when HTML/SVG defines it for that - * element, or when it belongs to an open family. The reflection check covers - * `readonly`/`maxlength`/`colspan` and every other per-tag attribute - * automatically instead of a hand-kept table that would rot. - */ -function isKnownAttribute(element: Element, attribute: string): boolean { - const name = attribute.toLowerCase(); - - if (OPEN_PREFIXES.some((prefix) => name.startsWith(prefix))) return true; - - // Inline event handlers reflect as IDL properties on every element; React - // never emits them as attributes, so one reaching the DOM means a - // handler-shaped prop was stringified onto it. Treat as a leak. - if (name.startsWith('on')) return false; - - const tag = element.tagName.toLowerCase(); - - if (element.namespaceURI === 'http://www.w3.org/2000/svg') { - return SVG_ATTRIBUTES.has(name) || GLOBAL_HTML_ATTRIBUTES.has(name); - } - - if (GLOBAL_HTML_ATTRIBUTES.has(name)) return true; - if (HAPPY_DOM_IDL_GAPS[tag]?.has(name)) return true; - - const idl = idlPropertiesFor(tag); - const alias = ATTRIBUTE_TO_IDL_ALIAS[name]; - if (alias && idl.has(alias.toLowerCase())) return true; - return idl.has(name); -} - -interface Leak { - tag: string; - attribute: string; - value: string; - outerHTML: string; -} - -/** Every unexplained attribute on `root` and its descendants. */ -function findLeaks(root: Element): Leak[] { - const leaks: Leak[] = []; - const elements: Element[] = [root, ...Array.from(root.querySelectorAll('*'))]; - for (const element of elements) { - for (const attribute of Array.from(element.attributes)) { - if (isKnownAttribute(element, attribute.name)) continue; - leaks.push({ - tag: element.tagName.toLowerCase(), - attribute: attribute.name, - value: attribute.value.slice(0, 80), - outerHTML: element.outerHTML.slice(0, 300), - }); - } - } - return leaks; -} - -/** Renders findings as the assertion's "actual", so a failure names everything. */ -function leakReport(target: string, leaks: Leak[]): string { - if (leaks.length === 0) return ''; - const lines = leaks.map( - (leak) => - ` <${leak.tag}> leaked ${leak.attribute}="${leak.value}"\n` + - ` in: ${leak.outerHTML}`, - ); - return `${target} leaked ${leaks.length} non-DOM attribute(s):\n${lines.join('\n')}`; -} +// The attribute judge, shared with `packages/fields`' gate (objectui#4434). +// Its calibration fixtures live next to it and prove it for both gates. +import { findLeaks, leakReport } from '@object-ui/test-support'; +import type { Leak } from '@object-ui/test-support'; /* ════════════════════════════════════════════════════════════════════════════ * The canaries @@ -660,70 +522,24 @@ afterEach(() => { }); /* ════════════════════════════════════════════════════════════════════════════ - * 1. The judge proves itself, BEFORE it is trusted on the sweep + * 1. The judge proves itself — in `@object-ui/test-support`, not here * ══════════════════════════════════════════════════════════════════════════ */ -/** Ordinary, correct markup — several attributes whose IDL name differs. */ -const CLEAN_FIXTURE = ` - -`; - -/** Every planted attribute here MUST be reported. */ -const PLANTED_LEAKS: ReadonlyArray = [ - ['schema', '[object Object]'], - ['events', '[object Object]'], - ['bind', 'data.revenue'], - ['props', '[object Object]'], - ['arialabel', 'Canary label'], - ['ariadescribedby', 'canary-desc'], - ['datasource', '[object Object]'], - ['colorvariant', 'success'], - ['zzcanary', 'CANARY-STR'], - ['zzcanaryobj', '[object Object]'], - ['zzcanarynum', '42'], - ['zzcanarycamel', 'CANARY-CAMEL'], - ['reference_to', 'contacts'], -]; - -describe('the leak judge is calibrated (objectui#3291 / #4425)', () => { - it('reports NOTHING on standard markup — no false positives', () => { - const host = document.createElement('div'); - host.innerHTML = CLEAN_FIXTURE; - document.body.appendChild(host); - try { - const leaks = findLeaks(host); - expect(leaks.map((l) => `<${l.tag}> ${l.attribute}="${l.value}"`).join('\n')).toBe(''); - } finally { - host.remove(); - } - }); - - it('reports EVERY planted fake attribute — no false negatives', () => { - const host = document.createElement('div'); - const planted = PLANTED_LEAKS.map(([name, value]) => `${name}="${value}"`).join(' '); - host.innerHTML = ``; - document.body.appendChild(host); - try { - const found = new Set(findLeaks(host).map((leak) => leak.attribute)); - const missed = PLANTED_LEAKS.map(([name]) => name).filter((name) => !found.has(name)); - expect(missed).toEqual([]); - } finally { - host.remove(); - } - }); -}); +/** + * This section used to hold a calibration pair: standard markup that must + * yield zero findings, and planted fake attributes that must all be reported. + * Both moved to `packages/test-support/src/__tests__/dom-leak-judge.test.tsx` + * with the judge (objectui#4434), unioned with the `packages/fields` gate's + * pair — which is how the ten recharts SVG attributes THIS file contributed to + * the shared list finally got a clean-markup fixture behind them. The judge is + * still proven before it is trusted on the sweep; it is proven once, for both + * gates, instead of once per copy. + * + * What stays here is the calibration this gate alone can do: section 2 below, + * which proves the CANARIES reach a widget through the real `SchemaRenderer` + * path. That is a property of the harness, not of the judge, and no shared + * module can assert it. + */ /* ════════════════════════════════════════════════════════════════════════════ * 2. The canary MECHANISM proves itself, end to end through the real path diff --git a/packages/fields/package.json b/packages/fields/package.json index a40cafe3f6..cdf74e08cd 100644 --- a/packages/fields/package.json +++ b/packages/fields/package.json @@ -50,6 +50,7 @@ "react-dom": "^18.0.0 || ^19.0.0" }, "devDependencies": { + "@object-ui/test-support": "workspace:*", "@tailwindcss/postcss": "^4.3.3", "@types/react": "19.2.18", "@types/react-dom": "19.2.4", diff --git a/packages/fields/src/__tests__/widget-dom-leak-e2e.test.tsx b/packages/fields/src/__tests__/widget-dom-leak-e2e.test.tsx index 1d898dd77d..d9de0f4b17 100644 --- a/packages/fields/src/__tests__/widget-dom-leak-e2e.test.tsx +++ b/packages/fields/src/__tests__/widget-dom-leak-e2e.test.tsx @@ -50,17 +50,26 @@ * 4. **This repo runs happy-dom, not jsdom** (`vitest.config.mts`), whose IDL * coverage has real gaps — `select[size]`, `option[label]`, `textarea[wrap]` * and `col[span]` are all standard HTML that happy-dom does not reflect. - * See {@link isKnownAttribute} for how the judge is built, and - * {@link HAPPY_DOM_IDL_GAPS} for each measured exception and its reason. - * Every one of those four was found BY the calibration fixture below, not + * See `@object-ui/test-support`'s `dom-leak-judge` for how the judge is + * built, and its `HAPPY_DOM_IDL_GAPS` for each measured exception and its + * reason. Every one of those four was found BY a calibration fixture, not * by guesswork. * - * ## The judge proves itself + * ## The judge proves itself — next to the judge, not here * - * Two fixtures run before the sweep: standard markup that must yield ZERO - * findings, and markup with planted fake attributes that must ALL be found. - * When a happy-dom upgrade changes IDL coverage, those fail loudly instead of - * the sweep going quietly blind. + * `isKnownAttribute` / `findLeaks` / `leakReport` are imported from + * `@object-ui/test-support`, which is where they now live for BOTH DOM-leak + * gates (objectui#4434). They used to be defined in this file and copied into + * `packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx`, and the + * two copies had already drifted. + * + * The calibration fixtures moved with them: standard markup that must yield + * ZERO findings, and markup with planted fake attributes that must ALL be + * found, now unioned from both gates' fixtures and run once in + * `packages/test-support/src/__tests__/dom-leak-judge.test.tsx`. When a + * happy-dom upgrade changes IDL coverage, that fails loudly instead of this + * sweep going quietly blind — and it now fails in ONE place instead of needing + * to be discovered twice. * * ## Deliberate coverage boundary * @@ -84,6 +93,11 @@ import { ComponentRegistry } from '@object-ui/core'; // Module scope: pulls in the form renderer's registration side effect. import '@object-ui/components'; import { SchemaRenderer } from '@object-ui/react'; +// The attribute judge, shared with `packages/app-shell`'s sweep gate +// (objectui#4434). It used to be defined in this file and copied into that one; +// its calibration fixtures now live next to it and prove it for both gates. +// `@object-ui/test-support` is private and never published — see its README. +import { findLeaks, leakReport } from '@object-ui/test-support'; import { withFieldCarrier } from '../withFieldCarrier'; import { FORM_FIELD_TYPES } from '../index'; @@ -131,263 +145,6 @@ import { ObjectRefField } from '../widgets/ObjectRefField'; import { FilterConditionField } from '../widgets/FilterConditionField'; import { RecipientPickerField } from '../widgets/RecipientPickerField'; -/* ════════════════════════════════════════════════════════════════════════════ - * The judge: is this attribute one HTML actually defines? - * ══════════════════════════════════════════════════════════════════════════ */ - -/** Open families. `data-*` is the one open family the widget contract declares. */ -const OPEN_PREFIXES = [ - 'data-', - 'aria-', - // `cmdk-root` / `cmdk-input` / `cmdk-list` … are marks the cmdk library puts - // on ITS OWN DOM. Not prop pass-through, and present on every cmdk-based - // picker (`object-ref`, `lookup`, …) the moment it renders. - 'cmdk-', -]; - -/** - * Global HTML attributes. Most are also IDL properties and would be caught by - * the reflection check below; they are listed because a missing IDL for a - * genuinely global attribute would otherwise read as a leak. - */ -const GLOBAL_HTML_ATTRIBUTES = new Set([ - 'id', 'class', 'style', 'title', 'lang', 'dir', 'hidden', 'tabindex', 'role', - 'slot', 'part', 'exportparts', 'itemid', 'itemprop', 'itemref', 'itemscope', - 'itemtype', 'translate', 'draggable', 'spellcheck', 'autocapitalize', - 'autocorrect', 'contenteditable', 'enterkeyhint', 'inputmode', 'accesskey', - 'nonce', 'is', 'popover', 'inert', 'autofocus', -]); - -/** - * Attributes whose IDL property is spelled differently enough that the - * case-insensitive reflection match below cannot find them. - */ -const ATTRIBUTE_TO_IDL_ALIAS: Record = { - 'class': 'className', - 'for': 'htmlFor', - 'accept-charset': 'acceptCharset', - 'http-equiv': 'httpEquiv', -}; - -/** - * MEASURED gaps in happy-dom's IDL, kept deliberately tiny — each entry is an - * attribute HTML defines that happy-dom's element does not reflect as a - * property, so reflection alone would report it as a leak. - */ -const HAPPY_DOM_IDL_GAPS: Record> = { - // `HTMLSelectElement.size` is standard; happy-dom does not define it. - select: new Set(['size']), - // `HTMLOptionElement.label` is standard; happy-dom does not define it. - option: new Set(['label']), - // `HTMLTextAreaElement.wrap` is standard; happy-dom does not define it. - textarea: new Set(['wrap']), - // `HTMLTableColElement.span` is standard; happy-dom does not define it. - col: new Set(['span']), - colgroup: new Set(['span']), -}; - -/** - * SVG needs its own list: the reflection trick does NOT hold for SVG under - * happy-dom (`SVGElement` reflects almost nothing), and lucide icons put a - * fixed set of presentation attributes on every icon they render. - */ -const SVG_ATTRIBUTES = new Set([ - 'xmlns', 'xmlns:xlink', 'version', 'viewbox', 'preserveaspectratio', - 'width', 'height', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'cx', 'cy', 'r', 'rx', - 'ry', 'd', 'points', 'transform', 'fill', 'fill-rule', 'fill-opacity', - 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', - 'stroke-dasharray', 'stroke-dashoffset', 'stroke-opacity', 'opacity', - 'clip-path', 'clip-rule', 'mask', 'offset', 'stop-color', 'stop-opacity', - 'gradientunits', 'gradienttransform', 'patternunits', 'text-anchor', - 'dominant-baseline', 'font-size', 'font-family', 'font-weight', 'vector-effect', - 'shape-rendering', 'focusable', 'overflow', 'color', -]); - -/** Lowercased IDL property names on a tag's prototype chain, cached per tag. */ -const idlCache = new Map>(); - -function idlPropertiesFor(tagName: string): Set { - const tag = tagName.toLowerCase(); - const cached = idlCache.get(tag); - if (cached) return cached; - - const names = new Set(); - const element = document.createElement(tag); - for (const own of Object.getOwnPropertyNames(element)) names.add(own.toLowerCase()); - for ( - let proto = Object.getPrototypeOf(element); - proto && proto !== Object.prototype; - proto = Object.getPrototypeOf(proto) - ) { - for (const name of Object.getOwnPropertyNames(proto)) names.add(name.toLowerCase()); - } - idlCache.set(tag, names); - return names; -} - -/** - * The rule: an attribute is legitimate when HTML/SVG defines it for that - * element, or when it belongs to an open family. - * - * The reflection check ("does the element's prototype chain carry a property - * with this name, case-insensitively?") is what makes this maintainable — it - * covers `readonly→readOnly`, `maxlength→maxLength`, `colspan→colSpan` and - * every other per-tag attribute automatically, instead of a hand-kept table - * per element type that would rot. - */ -function isKnownAttribute(element: Element, attribute: string): boolean { - const name = attribute.toLowerCase(); - - if (OPEN_PREFIXES.some((prefix) => name.startsWith(prefix))) return true; - - // Inline event handlers (`onclick`) reflect as IDL properties on every - // element; React never emits them as attributes, so reaching one means a - // handler-shaped prop was stringified onto the DOM. Treat as a leak. - if (name.startsWith('on')) return false; - - const tag = element.tagName.toLowerCase(); - - if (element.namespaceURI === 'http://www.w3.org/2000/svg') { - return SVG_ATTRIBUTES.has(name) || GLOBAL_HTML_ATTRIBUTES.has(name); - } - - if (GLOBAL_HTML_ATTRIBUTES.has(name)) return true; - if (HAPPY_DOM_IDL_GAPS[tag]?.has(name)) return true; - - const idl = idlPropertiesFor(tag); - const alias = ATTRIBUTE_TO_IDL_ALIAS[name]; - if (alias && idl.has(alias.toLowerCase())) return true; - return idl.has(name); -} - -interface Leak { - tag: string; - attribute: string; - value: string; - outerHTML: string; -} - -/** Every unexplained attribute on `root` and its descendants. */ -function findLeaks(root: Element): Leak[] { - const leaks: Leak[] = []; - const elements: Element[] = [root, ...Array.from(root.querySelectorAll('*'))]; - for (const element of elements) { - for (const attribute of Array.from(element.attributes)) { - if (isKnownAttribute(element, attribute.name)) continue; - leaks.push({ - tag: element.tagName.toLowerCase(), - attribute: attribute.name, - value: attribute.value, - outerHTML: element.outerHTML.slice(0, 400), - }); - } - } - return leaks; -} - -/** - * Renders the finding as the assertion's "actual" value, so a failure names - * the widget, the element, the attribute, its value and the markup. A 46-widget - * sweep failing as `expected [] to equal [ …47 items ]` is unusable. - */ -function leakReport(widgetType: string, variant: string, leaks: Leak[]): string { - if (leaks.length === 0) return ''; - const lines = leaks.map( - (leak) => - ` <${leak.tag}> leaked ${leak.attribute}="${leak.value}"\n` + - ` in: ${leak.outerHTML}`, - ); - return ( - `field:${widgetType} [${variant}] leaked ${leaks.length} non-DOM ` + - `attribute(s):\n${lines.join('\n')}` - ); -} - -/* ════════════════════════════════════════════════════════════════════════════ - * The judge proves itself, BEFORE it is trusted on 46 widgets - * ══════════════════════════════════════════════════════════════════════════ */ - -/** - * Ordinary, correct markup. Every attribute here is one HTML defines, several - * chosen precisely because their IDL name differs from the attribute - * (`readonly`/`maxlength`/`colspan`/`class`/`for`), plus the two happy-dom IDL - * gaps (`select[size]`, `option[label]`) and a cmdk mark. - */ -const CLEAN_FIXTURE = ` - -`; - -/** Every planted attribute here MUST be reported. */ -const PLANTED_LEAKS: ReadonlyArray = [ - // The renderer-only props that reached the DOM in the audit. - ['schema', '[object Object]'], - ['error', 'Title is required'], - ['emptyhint', 'Select country first'], - ['datasource', '[object Object]'], - ['dependentvalues', '[object Object]'], - ['dependson', 'country'], - ['inputtype', 'text'], - ['compact', 'true'], - ['onselectrecord', 'function'], - // The SDUI-only extra. - ['label', 'Title'], - // The open tail: arbitrary keys an author wrote on the field config. - ['zzcanary', 'CANARY-STR'], - ['zzcanaryobj', '[object Object]'], - ['zzcanarynum', '42'], - ['zzcanarycamel', 'CANARY-CAMEL'], - ['reference_to', 'contacts'], -]; - -describe('the leak judge is calibrated (objectui#3291)', () => { - it('reports NOTHING on standard markup — no false positives', () => { - const host = document.createElement('div'); - host.innerHTML = CLEAN_FIXTURE; - document.body.appendChild(host); - try { - const leaks = findLeaks(host); - expect( - leaks.map((l) => `<${l.tag}> ${l.attribute}="${l.value}"`).join('\n'), - ).toBe(''); - } finally { - host.remove(); - } - }); - - it('reports EVERY planted fake attribute — no false negatives', () => { - const host = document.createElement('div'); - const planted = PLANTED_LEAKS.map(([name, value]) => `${name}="${value}"`).join(' '); - // On an , so nothing can be excused by a permissive container. - host.innerHTML = ``; - document.body.appendChild(host); - try { - const found = new Set(findLeaks(host).map((leak) => leak.attribute)); - const missed = PLANTED_LEAKS.map(([name]) => name).filter((name) => !found.has(name)); - expect(missed).toEqual([]); - expect(found.size).toBe(PLANTED_LEAKS.length); - } finally { - host.remove(); - } - }); -}); - /* ════════════════════════════════════════════════════════════════════════════ * Every registered field widget, both hosts * ══════════════════════════════════════════════════════════════════════════ */ @@ -546,7 +303,7 @@ describe('no field widget leaks non-DOM props to the DOM (objectui#3291)', () => it.each(types)('field:%s — form path, plain field', async (type) => { renderForm(fieldConfig(type), false); const row = await formRow(); - expect(leakReport(type, 'form/plain', findLeaks(row))).toBe(''); + expect(leakReport(`field:${type} [form/plain]`, findLeaks(row))).toBe(''); }); it.each(types)('field:%s — form path, author-written extra keys', async (type) => { @@ -555,7 +312,7 @@ describe('no field widget leaks non-DOM props to the DOM (objectui#3291)', () => // put on the field config arrives at the widget as a prop. renderForm(fieldConfig(type, AUTHORED_EXTRAS), false); const row = await formRow(); - expect(leakReport(type, 'form/authored-extras', findLeaks(row))).toBe(''); + expect(leakReport(`field:${type} [form/authored-extras]`, findLeaks(row))).toBe(''); }); it.each(types)('field:%s — form path, after a real validation failure', async (type) => { @@ -572,7 +329,9 @@ describe('no field widget leaks non-DOM props to the DOM (objectui#3291)', () => ); }); - expect(leakReport(type, 'form/validation-error', findLeaks(await formRow()))).toBe(''); + expect( + leakReport(`field:${type} [form/validation-error]`, findLeaks(await formRow())), + ).toBe(''); }); it.each(types)('field:%s — SDUI path, plain node', async (type) => { @@ -583,7 +342,7 @@ describe('no field widget leaks non-DOM props to the DOM (objectui#3291)', () => , ); await waitFor(() => expect(container.firstElementChild).toBeTruthy()); - expect(leakReport(type, 'sdui/plain', findLeaks(container))).toBe(''); + expect(leakReport(`field:${type} [sdui/plain]`, findLeaks(container))).toBe(''); }); it.each(types)('field:%s — SDUI path, author-written extra keys', async (type) => { @@ -593,6 +352,6 @@ describe('no field widget leaks non-DOM props to the DOM (objectui#3291)', () => />, ); await waitFor(() => expect(container.firstElementChild).toBeTruthy()); - expect(leakReport(type, 'sdui/authored-extras', findLeaks(container))).toBe(''); + expect(leakReport(`field:${type} [sdui/authored-extras]`, findLeaks(container))).toBe(''); }); }); diff --git a/packages/test-support/README.md b/packages/test-support/README.md new file mode 100644 index 0000000000..72168b857d --- /dev/null +++ b/packages/test-support/README.md @@ -0,0 +1,57 @@ +# @object-ui/test-support + +Internal test-support modules shared between packages' test suites. + +**This package is `private: true` and is never published.** It exists so that +two test suites in two different packages can share one implementation of +something they both need, without that shared thing becoming public API of a +released package. + +## Why it exists at all + +`packages/fields` and `packages/app-shell` each run a DOM-leak gate, and both +needed the same "is this attribute one HTML actually defines" judge. Neither +package can import the other's test file, so the judge was copied — and the two +copies had already drifted apart by the time objectui#4434 was filed. + +The three constraints that ruled out every other home: + +- both `packages/fields` and `packages/app-shell` must be able to reach it; +- it must **not** become published runtime API surface (which a `./test-support` + subpath export on a released package would be — an entry in a published + `exports` map is public API whatever it is named); +- no unpublished deep-subpath imports. objectui#4325 ruled that shape out after + `@object-ui/fields/widgets/MarkdownContent` — a specifier only this repo's + vitest alias could resolve, `TS2882` for `tsc`, and unresolvable for anyone + outside the repo. A package's surface is its index, including this one. + +A private workspace package satisfies all three at once, and costs one +`devDependency` line in each consumer. + +## What may live here + +Test *infrastructure* that more than one package's tests need: something both +suites must agree on, where two copies drifting apart is the real risk. + +Not: fixtures for one package's own tests (those belong next to them, e.g. +`packages/components/src/__tests__/test-utils.tsx`), and not anything shipped +code imports — nothing in `src/` of a released package may import this. + +## Contents + +- `src/dom-leak-judge.ts` — the DOM-leak attribute judge: `isKnownAttribute`, + `findLeaks`, `leakReport`, the happy-dom IDL gap table, the SVG presentation + list and the open attribute families. Consumed by + `packages/fields/src/__tests__/widget-dom-leak-e2e.test.tsx` and + `packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx`. +- `src/__tests__/dom-leak-judge.test.tsx` — the calibration fixtures that prove + the judge, once, for both gates. + +## Conventions + +- Consumers add `"@object-ui/test-support": "workspace:*"` to + **`devDependencies`** — never `dependencies`, since no consumer ships it. +- Import the package root (`@object-ui/test-support`), never a deep path. +- There is no build: consumers resolve the TypeScript source through the + `exports` map. `pnpm --filter @object-ui/test-support type-check` reads both + the modules and their tests in one program. diff --git a/packages/test-support/package.json b/packages/test-support/package.json new file mode 100644 index 0000000000..e324e9891a --- /dev/null +++ b/packages/test-support/package.json @@ -0,0 +1,31 @@ +{ + "name": "@object-ui/test-support", + "version": "17.4.0", + "private": true, + "type": "module", + "license": "MIT", + "description": "Internal, never-published test-support modules shared by other packages' test suites", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "scripts": { + "type-check": "tsc --noEmit", + "lint": "eslint ." + }, + "devDependencies": { + "typescript": "^6.0.3" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/objectstack-ai/objectui.git", + "directory": "packages/test-support" + }, + "bugs": { + "url": "https://github.com/objectstack-ai/objectui/issues" + } +} diff --git a/packages/test-support/src/__tests__/dom-leak-judge.test.tsx b/packages/test-support/src/__tests__/dom-leak-judge.test.tsx new file mode 100644 index 0000000000..5f6e4fdefb --- /dev/null +++ b/packages/test-support/src/__tests__/dom-leak-judge.test.tsx @@ -0,0 +1,167 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * THE JUDGE PROVES ITSELF — once, for every gate that imports it + * (objectui#4434). + * + * Two fixtures, and between them they are the whole reason the judge may be + * trusted by a gate that scans hundreds of elements: + * + * - CLEAN: ordinary, correct markup that must yield ZERO findings. This is + * the half that catches a happy-dom upgrade CHANGING IDL coverage — every + * entry in `HAPPY_DOM_IDL_GAPS` and every attribute in `SVG_ATTRIBUTES` + * that a gate relies on is exercised here, so an exception that stops being + * needed, or a reflection that stops working, fails loudly instead of + * quietly turning a sweep into noise. + * - PLANTED: markup carrying fake attributes that must ALL be reported. This + * is the half that catches the judge going blind — the failure mode where a + * gate reports "no leaks" because it stopped being able to see any. + * + * ## These fixtures are the UNION of the two that used to exist + * + * Both gates carried their own calibration pair, and each only exercised the + * knowledge ITS gate happened to need: + * + * - the fields pair (#3291) covered lucide's SVG attributes, ``/``, + * the cmdk marks and the four happy-dom IDL gaps it had measured, and + * planted the field-renderer prop names; + * - the sweep pair (#4425) covered a subset of that markup, and planted the + * SDUI injection names (`schema`, `bind`, `events`, `props`, the camelCase + * ARIA pair) instead. + * + * Unioning the judge without unioning the fixtures would have left the merged + * knowledge half-calibrated, and in one specific place it was calibrated by + * NOBODY: the ten recharts marker/gradient/pattern attributes the sweep added + * to `SVG_ATTRIBUTES` had no clean-markup fixture behind them in either file. + * They do now — see the `` block below. + */ + +import { describe, it, expect } from 'vitest'; +import { findLeaks, isKnownAttribute } from '../dom-leak-judge'; + +/** + * Ordinary, correct markup. Every attribute here is one HTML defines, several + * chosen precisely because their IDL name differs from the attribute + * (`readonly`/`maxlength`/`colspan`/`class`/`for`), plus every happy-dom IDL + * gap the judge excepts (`select[size]`, `option[label]`, `textarea[wrap]`, + * `col[span]`, `colgroup[span]`) and a cmdk mark. + * + * The `` block is deliberately two producers' markup: the lucide icon + * shape both gates have always rendered, and — inside `` — the recharts + * marker/gradient/pattern attributes that only the app-shell sweep renders. + */ +const CLEAN_FIXTURE = ` + +`; + +/** + * Every planted attribute here MUST be reported — the union of what the two + * gates each planted, on one `` so nothing can be excused by a + * permissive container. + */ +const PLANTED_LEAKS: ReadonlyArray = [ + // ── The props a host INJECTS (#4425's sweep) ──────────────────────────── + ['schema', '[object Object]'], + ['events', '[object Object]'], + ['bind', 'data.revenue'], + ['props', '[object Object]'], + ['arialabel', 'Canary label'], + ['ariadescribedby', 'canary-desc'], + ['datasource', '[object Object]'], + ['colorvariant', 'success'], + // ── The renderer-only props that reached the DOM in the #3291 audit ───── + ['error', 'Title is required'], + ['emptyhint', 'Select country first'], + ['dependentvalues', '[object Object]'], + ['dependson', 'country'], + ['inputtype', 'text'], + ['compact', 'true'], + ['onselectrecord', 'function'], + // The SDUI-only extra. + ['label', 'Title'], + // ── The open tail: arbitrary keys an author wrote on the node ─────────── + ['zzcanary', 'CANARY-STR'], + ['zzcanaryobj', '[object Object]'], + ['zzcanarynum', '42'], + ['zzcanarycamel', 'CANARY-CAMEL'], + ['reference_to', 'contacts'], +]; + +describe('the DOM-leak judge is calibrated (objectui#3291 / #4425 / #4434)', () => { + it('reports NOTHING on standard markup — no false positives', () => { + const host = document.createElement('div'); + host.innerHTML = CLEAN_FIXTURE; + document.body.appendChild(host); + try { + const leaks = findLeaks(host); + expect( + leaks.map((l) => `<${l.tag}> ${l.attribute}="${l.value}"`).join('\n'), + ).toBe(''); + } finally { + host.remove(); + } + }); + + it('reports EVERY planted fake attribute — no false negatives', () => { + const host = document.createElement('div'); + const planted = PLANTED_LEAKS.map(([name, value]) => `${name}="${value}"`).join(' '); + // On an ``, so nothing can be excused by a permissive container. + host.innerHTML = ``; + document.body.appendChild(host); + try { + const found = new Set(findLeaks(host).map((leak) => leak.attribute)); + const missed = PLANTED_LEAKS.map(([name]) => name).filter((name) => !found.has(name)); + expect(missed).toEqual([]); + // Exact count, not "at least": a judge that reported the whole element + // would also satisfy the line above. + expect(found.size).toBe(PLANTED_LEAKS.length); + } finally { + host.remove(); + } + }); + + it('judges SVG by its own list, because reflection does not hold there', () => { + // The mechanism behind the `` block above, asserted directly: the + // recharts half of the union is SVG-namespaced markup, and `markerwidth` + // is not an IDL property of anything happy-dom builds. If this ever passes + // through reflection instead, the SVG list has stopped being load-bearing + // and the clean fixture would no longer be testing it. + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'marker'); + expect(isKnownAttribute(svg, 'markerWidth')).toBe(true); + expect(isKnownAttribute(svg, 'zzcanary')).toBe(false); + + const div = document.createElement('div'); + expect(isKnownAttribute(div, 'markerWidth')).toBe(false); + }); +}); diff --git a/packages/test-support/src/dom-leak-judge.ts b/packages/test-support/src/dom-leak-judge.ts new file mode 100644 index 0000000000..9212bf3bc7 --- /dev/null +++ b/packages/test-support/src/dom-leak-judge.ts @@ -0,0 +1,259 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * THE DOM-LEAK JUDGE: is this attribute one HTML actually defines? + * + * One judge, shared by every DOM-leak gate in the repo (objectui#4434). It + * answers exactly one question — given an element and an attribute name, is + * that attribute something HTML/SVG defines for that element, or does it only + * exist because a non-DOM prop was spread onto the element? — and it knows + * nothing about widgets, canaries, targets or ledgers. Those stay in each gate. + * + * ## Why it lives here instead of in a test file + * + * It used to live twice, inline, in two test files that cannot import each + * other: + * + * - `packages/fields/src/__tests__/widget-dom-leak-e2e.test.tsx` (#3291, the + * original) + * - `packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx` (#4425 + * phase 1, the generalized sweep) + * + * The copies had ALREADY diverged when objectui#4434 was filed: the sweep's SVG + * list carried ten recharts presentation attributes the fields copy never + * needed, and the two `findLeaks` records truncated differently. What this + * module encodes is measured ENVIRONMENT fact — happy-dom's IDL gaps, the SVG + * presentation surface, the open attribute families — so it drifts whenever + * happy-dom, lucide or recharts is upgraded. Discovering that drift twice, in + * two places, is how one judge silently becomes two disagreeing judges. + * + * The knowledge below is therefore the UNION of the two copies, and the + * calibration fixtures that prove it live next door in + * `__tests__/dom-leak-judge.test.tsx` — once, rather than once per gate. + * + * ## Where the line is + * + * This module holds exactly what was DUPLICATED, and nothing else. Canary + * sets, target enumerations, readiness selectors, the sweep's leak ledger and + * every assertion stay in the gate that owns them — including helpers that + * only ever existed in one gate (`leakedAttributeNames` is the sweep's ledger + * unit and stays there). A judge that starts accumulating one gate's policy is + * back to being two judges wearing one name. + * + * ## The mechanism, and why it is reflection rather than a table + * + * The core check is IDL reflection: "does this element's prototype chain carry + * a property with this name, case-insensitively?". That covers + * `readonly→readOnly`, `maxlength→maxLength`, `colspan→colSpan` and every other + * per-tag attribute automatically, instead of a hand-kept table per element + * type that would rot. Everything else in this file is the measured exceptions + * where reflection alone gives the wrong answer. + */ + +/** + * Open families. `data-*` is the one open family the widget contract declares. + */ +export const OPEN_PREFIXES: readonly string[] = [ + 'data-', + 'aria-', + // `cmdk-root` / `cmdk-input` / `cmdk-list` … are marks the cmdk library puts + // on ITS OWN DOM. Not prop pass-through, and present on every cmdk-based + // picker (`object-ref`, `lookup`, …) the moment it renders. + 'cmdk-', +]; + +/** + * Global HTML attributes, legitimate on any element. Most are also IDL + * properties and would be caught by the reflection check below; they are listed + * because a missing IDL for a genuinely global attribute would otherwise read + * as a leak. + */ +export const GLOBAL_HTML_ATTRIBUTES: ReadonlySet = new Set([ + 'id', 'class', 'style', 'title', 'lang', 'dir', 'hidden', 'tabindex', 'role', + 'slot', 'part', 'exportparts', 'itemid', 'itemprop', 'itemref', 'itemscope', + 'itemtype', 'translate', 'draggable', 'spellcheck', 'autocapitalize', + 'autocorrect', 'contenteditable', 'enterkeyhint', 'inputmode', 'accesskey', + 'nonce', 'is', 'popover', 'inert', 'autofocus', +]); + +/** + * Attributes whose IDL property is spelled differently enough that the + * case-insensitive reflection match below cannot find them. + */ +export const ATTRIBUTE_TO_IDL_ALIAS: Readonly> = { + 'class': 'className', + 'for': 'htmlFor', + 'accept-charset': 'acceptCharset', + 'http-equiv': 'httpEquiv', +}; + +/** + * MEASURED gaps in happy-dom's IDL, kept deliberately tiny — each entry is an + * attribute HTML defines that happy-dom's element does not reflect as a + * property, so reflection alone would report it as a leak. + * + * Every one of these was found BY the calibration fixture next door, not by + * guesswork, and the fixture is what fails loudly when a happy-dom upgrade + * changes IDL coverage in either direction. + */ +export const HAPPY_DOM_IDL_GAPS: Readonly>> = { + // `HTMLSelectElement.size` is standard; happy-dom does not define it. + select: new Set(['size']), + // `HTMLOptionElement.label` is standard; happy-dom does not define it. + option: new Set(['label']), + // `HTMLTextAreaElement.wrap` is standard; happy-dom does not define it. + textarea: new Set(['wrap']), + // `HTMLTableColElement.span` is standard; happy-dom does not define it. + col: new Set(['span']), + colgroup: new Set(['span']), +}; + +/** + * SVG needs its own list: the reflection trick does NOT hold for SVG under + * happy-dom (`SVGElement` reflects almost nothing), and the icon/chart + * libraries put a fixed set of presentation attributes on the markup they + * render. + * + * Two producers, one list. The first block is what lucide icons emit — the + * only SVG the `packages/fields` gate ever renders. The second block is + * recharts' marker/gradient/pattern markup, which only the app-shell sweep + * renders; it is the divergence objectui#4434 was filed for, and the union is + * what makes one judge serve both gates. + */ +export const SVG_ATTRIBUTES: ReadonlySet = new Set([ + 'xmlns', 'xmlns:xlink', 'version', 'viewbox', 'preserveaspectratio', + 'width', 'height', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'cx', 'cy', 'r', 'rx', + 'ry', 'd', 'points', 'transform', 'fill', 'fill-rule', 'fill-opacity', + 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', + 'stroke-dasharray', 'stroke-dashoffset', 'stroke-opacity', 'opacity', + 'clip-path', 'clip-rule', 'mask', 'offset', 'stop-color', 'stop-opacity', + 'gradientunits', 'gradienttransform', 'patternunits', 'text-anchor', + 'dominant-baseline', 'font-size', 'font-family', 'font-weight', + 'vector-effect', 'shape-rendering', 'focusable', 'overflow', 'color', + // recharts markup — the sweep-only half of the union (objectui#4434). + 'orient', 'refx', 'refy', 'markerwidth', 'markerheight', 'markerunits', + 'patterncontentunits', 'spreadmethod', 'gradientscale', 'pathlength', +]); + +/** Lowercased IDL property names on a tag's prototype chain, cached per tag. */ +const idlCache = new Map>(); + +function idlPropertiesFor(tagName: string): Set { + const tag = tagName.toLowerCase(); + const cached = idlCache.get(tag); + if (cached) return cached; + + const names = new Set(); + const element = document.createElement(tag); + for (const own of Object.getOwnPropertyNames(element)) names.add(own.toLowerCase()); + for ( + let proto = Object.getPrototypeOf(element); + proto && proto !== Object.prototype; + proto = Object.getPrototypeOf(proto) + ) { + for (const name of Object.getOwnPropertyNames(proto)) names.add(name.toLowerCase()); + } + idlCache.set(tag, names); + return names; +} + +/** + * The rule: an attribute is legitimate when HTML/SVG defines it for that + * element, or when it belongs to an open family. + * + * The reflection check ("does the element's prototype chain carry a property + * with this name, case-insensitively?") is what makes this maintainable — it + * covers `readonly→readOnly`, `maxlength→maxLength`, `colspan→colSpan` and + * every other per-tag attribute automatically, instead of a hand-kept table + * per element type that would rot. + */ +export function isKnownAttribute(element: Element, attribute: string): boolean { + const name = attribute.toLowerCase(); + + if (OPEN_PREFIXES.some((prefix) => name.startsWith(prefix))) return true; + + // Inline event handlers (`onclick`) reflect as IDL properties on every + // element; React never emits them as attributes, so reaching one means a + // handler-shaped prop was stringified onto the DOM. Treat as a leak. + if (name.startsWith('on')) return false; + + const tag = element.tagName.toLowerCase(); + + if (element.namespaceURI === 'http://www.w3.org/2000/svg') { + return SVG_ATTRIBUTES.has(name) || GLOBAL_HTML_ATTRIBUTES.has(name); + } + + if (GLOBAL_HTML_ATTRIBUTES.has(name)) return true; + if (HAPPY_DOM_IDL_GAPS[tag]?.has(name)) return true; + + const idl = idlPropertiesFor(tag); + const alias = ATTRIBUTE_TO_IDL_ALIAS[name]; + if (alias && idl.has(alias.toLowerCase())) return true; + return idl.has(name); +} + +/** One unexplained attribute, with enough context to name it in a failure. */ +export interface Leak { + tag: string; + attribute: string; + value: string; + outerHTML: string; +} + +/** + * How much of a leaked value and its markup a {@link Leak} record keeps. + * + * The two copies disagreed here and neither bound is load-bearing for any + * assertion — both gates assert on attribute NAMES, so these numbers only + * shape the failure message. The union takes the defensive answer on each + * axis: the value is BOUNDED (the fields copy kept it whole, and a leaked + * value can be an arbitrarily long serialized object), and the markup keeps + * the WIDER of the two bounds, because the markup is what tells you which + * element in a 46-widget sweep produced the finding. + */ +const VALUE_CHARS = 80; +const OUTER_HTML_CHARS = 400; + +/** Every unexplained attribute on `root` and its descendants. */ +export function findLeaks(root: Element): Leak[] { + const leaks: Leak[] = []; + const elements: Element[] = [root, ...Array.from(root.querySelectorAll('*'))]; + for (const element of elements) { + for (const attribute of Array.from(element.attributes)) { + if (isKnownAttribute(element, attribute.name)) continue; + leaks.push({ + tag: element.tagName.toLowerCase(), + attribute: attribute.name, + value: attribute.value.slice(0, VALUE_CHARS), + outerHTML: element.outerHTML.slice(0, OUTER_HTML_CHARS), + }); + } + } + return leaks; +} + +/** + * Renders the findings as the assertion's "actual" value, so a failure names + * the element, the attribute, its value and the markup. A 46-widget sweep + * failing as `expected [] to equal [ …47 items ]` is unusable. + * + * `label` is whatever identifies the thing being scanned to the gate that owns + * it — the fields gate passes `field: []`, the app-shell sweep + * passes the registry type. Unifying on one label parameter is what let both + * call sites keep the exact message they produced before. + */ +export function leakReport(label: string, leaks: readonly Leak[]): string { + if (leaks.length === 0) return ''; + const lines = leaks.map( + (leak) => + ` <${leak.tag}> leaked ${leak.attribute}="${leak.value}"\n` + + ` in: ${leak.outerHTML}`, + ); + return `${label} leaked ${leaks.length} non-DOM attribute(s):\n${lines.join('\n')}`; +} diff --git a/packages/test-support/src/index.ts b/packages/test-support/src/index.ts new file mode 100644 index 0000000000..b0b6b1de41 --- /dev/null +++ b/packages/test-support/src/index.ts @@ -0,0 +1,31 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `@object-ui/test-support` — the surface. + * + * This package is `private: true` and is never published, so this index is not + * public API; it is the ONE specifier other packages' test suites import. The + * indirection is the objectui#4325 lesson applied ahead of time: a deep + * subpath into another package (`@object-ui/fields/widgets/MarkdownContent`) + * resolved only through this repo's vitest alias, was TS2882 for `tsc`, and was + * ruled out rather than minted as permanent public API. A package's surface is + * its index — including this one. + */ + +export { + ATTRIBUTE_TO_IDL_ALIAS, + GLOBAL_HTML_ATTRIBUTES, + HAPPY_DOM_IDL_GAPS, + OPEN_PREFIXES, + SVG_ATTRIBUTES, + findLeaks, + isKnownAttribute, + leakReport, +} from './dom-leak-judge'; +export type { Leak } from './dom-leak-judge'; diff --git a/packages/test-support/tsconfig.json b/packages/test-support/tsconfig.json new file mode 100644 index 0000000000..7aa277391a --- /dev/null +++ b/packages/test-support/tsconfig.json @@ -0,0 +1,19 @@ +{ + // One project, covering BOTH the modules and their own tests — the shape + // `packages/fields` uses. This package emits nothing (no `build` script, no + // `outDir`, and the root config's `noEmit` is inherited): consumers import + // its TypeScript source directly through the `exports` map, because it is + // `private: true` and never published, so there is no `dist` for anyone to + // resolve and no published surface to keep stable. + // + // Because there is no build config to exclude tests from, there is also no + // second `tsconfig.test.json` to chain: `tsc --noEmit` reads + // `src/__tests__/dom-leak-judge.test.tsx` as part of the same program, which + // is what `scripts/check-type-check-coverage.mjs` asks of every package with + // test files. + "extends": "../../tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx" + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36e4f8cef5..3dfce7f17f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -784,6 +784,9 @@ importers: '@object-ui/plugin-view': specifier: workspace:* version: link:../plugin-view + '@object-ui/test-support': + specifier: workspace:* + version: link:../test-support '@types/node': specifier: ^26.1.2 version: 26.1.2 @@ -1257,6 +1260,9 @@ importers: specifier: ^3.6.0 version: 3.6.0 devDependencies: + '@object-ui/test-support': + specifier: workspace:* + version: link:../test-support '@tailwindcss/postcss': specifier: ^4.3.3 version: 4.3.3 @@ -2733,6 +2739,12 @@ importers: specifier: ^17.0.0-rc.6 version: 17.0.0-rc.6(ai@7.0.56(zod@4.4.3)) + packages/test-support: + devDependencies: + typescript: + specifier: ^6.0.3 + version: 6.0.3 + packages/types: dependencies: '@objectstack/spec': diff --git a/vitest.config.mts b/vitest.config.mts index 1c767f300f..23f757240d 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -276,6 +276,11 @@ export default defineConfig({ '@object-ui/permissions': path.resolve(__dirname, './packages/permissions/src'), '@object-ui/collaboration': path.resolve(__dirname, './packages/collaboration/src'), '@object-ui/app-shell': path.resolve(__dirname, './packages/app-shell/src'), + // Private, never-published test-support package (objectui#4434). Aliased + // like every other workspace package so a test's import resolves to the + // same source file `tsc` reads through the package's `exports` map, + // rather than depending on how Vite treats a symlinked dependency. + '@object-ui/test-support': path.resolve(__dirname, './packages/test-support/src'), '@': path.resolve(__dirname, './packages/components/src'), '@object-ui/ui': path.resolve(__dirname, './packages/ui/src'), }, From d006a001be01e5173824085b08ac904e91dd9b30 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 21:49:51 +0000 Subject: [PATCH 2/2] docs(quick-reference): name the new private test-support package (#4434) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scripts/__tests__/quick-reference-commands-4149.test.ts` requires the `packages/*` row to name every private package under `packages/`, so that a published count smaller than the directory count is explained rather than "corrected" back (objectui#4149). Adding `packages/test-support` makes it the second one. The published count is unchanged at 38 — the new package is `private: true`. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 --- QUICK_REFERENCE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/QUICK_REFERENCE.md b/QUICK_REFERENCE.md index e9ce55a421..bbed6dfee3 100644 --- a/QUICK_REFERENCE.md +++ b/QUICK_REFERENCE.md @@ -74,7 +74,7 @@ pnpm changeset publish # Publish to npm (CI only) | Path | Purpose | | --- | --- | -| `packages/*` | 38 published packages (`@object-ui/*`), plus the private `vscode-extension` | +| `packages/*` | 38 published packages (`@object-ui/*`), plus the private `vscode-extension` and `test-support` (test-only, never released) | | `apps/console` | Full ObjectUI console app (Vite + React) | | `apps/site` | Public docs site at (fumadocs) | | `examples/*` | Runnable examples and the schema catalog — see [`examples/README.md`](./examples/README.md) |