test(tf): convert BC tests to axiomExcluded — D-ban-runtime-addwidget (wave-10)

Per AXIOMS.md §Axiom-Excluded Test Annotation Policy (new wave-10) and
AGENTS.md Rule 11, BC tests asserting on the removed v2
NodeHandle.addWidget / addDOMWidget surface are wrapped with the new
`axiomExcluded({...})` helper rather than deleted.

The annotated tests continue to run via vitest test.fails — if the v2
surface is ever re-introduced (intentionally or by regression) the
expected-failure flips to a real failure and surfaces the policy
violation.

## New shared helper
- `src/extension-api-v2/__tests__/helpers/axiomExcluded.ts` — thin
  wrapper around vitest test.fails that attaches structured metadata
  to `task.meta.annotations`: axiom id, ADR path, rationale, migration
  paths, optional restoration cross-ref. Reporters and tooling can
  pivot on the annotation to surface a per-axiom dashboard.

## BC tests converted
- `bc-05.v2.test.ts` (8 tests, all excluded — entire file is
  addDOMWidget-related). Removed `getDOMWidgetElement` import (deleted
  in foundation commit `d5d5692928`) and added a local stub returning
  undefined so the remaining test references compile.
- `bc-05.migration.test.ts` (6 tests, all excluded — entire file is
  v1↔v2 addDOMWidget parity).
- `bc-11.v2.test.ts` (3 of 11 tests excluded — only the
  `NodeHandle.addWidget — managed widget list mutation (S2.N16)`
  describe block; setValue/setHidden/setDisabled/setOption tests stay
  unchanged because those v2 surfaces remain valid).
- `bc-11.migration.test.ts` (4 of 10 tests excluded — only the
  `node.widgets.push/splice → NodeHandle.addWidget (S2.N16)` describe
  block; value/option parity tests stay unchanged).

## Harness
- `src/extension-api-v2/__tests__/harness/v2Runtime.ts` — comment out
  the `addWidget` and `addDOMWidget` mock implementations on the
  NodeHandle stub. With the public surface gone, leaving these mocks
  would mask the absence in harness-backed tests. Comment block links
  to D-ban-runtime-addwidget. `widgetCounter` removed (unused).

## Compat-floor doctrine retired
- Removed the
  `// compat-floor: blast_radius 5.45 ≥ 2.0 — MUST pass before v2
  ships` header annotation from all 4 converted BC test files. Per the
  new AXIOMS.md section, axioms and ADRs are the source of truth for
  v2 surface composition — compat-floor based on consumer counts
  conflated migration evidence with surface obligation.

## Validation
- tsc: no new errors introduced on the touched files (pre-existing
  TS2305/TS2353/TS2307 errors per AGENTS.md Rule 8 carve-out remain)
- BC tests for v1 (bc-05.v1, bc-11.v1) need no changes — bc-05.v1 uses
  a local synthetic addDOMWidget helper (not the LGraphNode prototype);
  bc-11.v1 doesn't reference addWidget at all
- ext-api/*-v2 stack CI carve-out per AGENTS.md Rule 8 — codecov/patch,
  e2e, perf, playwright failures don't block forward motion until Phase
  B rebase

Refs: AXIOMS.md A15 + §Axiom-Excluded Test Annotation Policy;
AGENTS.md Rule 11; foundation `d5d5692928`; pkg `7c4d713ce4`.
This commit is contained in:
bymyself
2026-05-20 15:09:16 -07:00
parent e775e76bda
commit 7fb6c17dc6
6 changed files with 239 additions and 73 deletions

View File

@@ -1,11 +1,39 @@
// Category: BC.05 — Custom DOM widgets and node sizing
// DB cross-ref: S4.W2, S2.N11
// Exemplar: https://github.com/Lightricks/ComfyUI-LTXVideo/blob/main/web/js/sparse_track_editor.js#L218
// compat-floor: blast_radius 5.45 ≥ 2.0 — MUST pass before v2 ships
// Migration: v1 node.addDOMWidget + node.computeSize → v2 NodeHandle.addDOMWidget + WidgetHandle.setHeight
//
// AXIOM-EXCLUDED (wave-10, D-ban-runtime-addwidget, AXIOMS.md A15):
// This file asserts v1↔v2 parity for runtime DOM widget addition.
// v2 NodeHandle.addDOMWidget is removed per A15 — runtime widget addition
// is forbidden in the new API. All tests are wrapped with
// `axiomExcluded({...})` (vitest test.fails) and continue to run as
// regression alarms.
//
// Migration: v1 `node.addDOMWidget(...)` extensions migrate to one of —
// - Declare in Python INPUT_TYPES (preferred)
// - Boxed widget (BBOX-style)
// - Non-widget UI primitive via defineNode/defineExtension setup()
//
// The "compat-floor blast_radius ≥ 2.0 MUST pass before v2 ships"
// doctrine is retired (AXIOMS.md §Axiom-Excluded Test Annotation Policy).
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { axiomExcluded } from './helpers/axiomExcluded'
const excluded = axiomExcluded({
axiom: 'A15',
adr: 'decisions/D-ban-runtime-addwidget.md',
rationale:
'v2 NodeHandle does not expose addDOMWidget; the v1↔v2 parity scenario this file tests is no longer valid.',
migration: [
'Declare in Python INPUT_TYPES',
'Boxed widget (e.g. BBOX [x,y,w,h])',
'Non-widget UI primitive via defineNode/defineExtension setup()'
],
restoration: 'D-ban-runtime-addwidget §Restoration criteria'
})
// ── Mock world (same pattern as bc-01.migration.test.ts) ──────────────────────
// vi.hoisted factory runs before imports — keep handle creation inline.
@@ -144,7 +172,7 @@ describe('BC.05 migration — custom DOM widgets and node sizing', () => {
})
describe('widget registration parity (S4.W2)', () => {
it('v1 addDOMWidget and v2 addDOMWidget both register a widget with the given name', () => {
excluded('v1 addDOMWidget and v2 addDOMWidget both register a widget with the given name', () => {
const el = makeDiv()
// v1 pattern
@@ -168,7 +196,7 @@ describe('BC.05 migration — custom DOM widgets and node sizing', () => {
expect(registeredNames).toEqual(v1Names)
})
it('v1 opts.getHeight() value matches the v2 height option stored in the dispatch command', () => {
excluded('v1 opts.getHeight() value matches the v2 height option stored in the dispatch command', () => {
const el = makeDiv(0) // offsetHeight irrelevant
const reportedHeight = 200
@@ -201,7 +229,7 @@ describe('BC.05 migration — custom DOM widgets and node sizing', () => {
expect(createCmd?.options.__domHeight).toBe(v1Height)
})
it('v2 registers the same number of DOM widgets as v1 for a multi-widget node', () => {
excluded('v2 registers the same number of DOM widgets as v1 for a multi-widget node', () => {
// v1 pattern: two addDOMWidget calls
const v1Node = createV1Node(3)
v1Node.addDOMWidget('widgetA', 'custom', makeDiv(50))
@@ -229,7 +257,7 @@ describe('BC.05 migration — custom DOM widgets and node sizing', () => {
})
describe('computeSize elimination (S2.N11)', () => {
it('v2 setHeight produces a SetWidgetOption command; v1 requires a computeSize override for the same effect', () => {
excluded('v2 setHeight produces a SetWidgetOption command; v1 requires a computeSize override for the same effect', () => {
const el = makeDiv(100)
const newHeight = 400
@@ -264,7 +292,7 @@ describe('BC.05 migration — custom DOM widgets and node sizing', () => {
})
describe('cleanup parity', () => {
it('v1 requires manual removal in onRemoved; v2 auto-removes the element via scope disposal', () => {
excluded('v1 requires manual removal in onRemoved; v2 auto-removes the element via scope disposal', () => {
const el = makeDiv()
document.body.appendChild(el)
@@ -297,7 +325,7 @@ describe('BC.05 migration — custom DOM widgets and node sizing', () => {
expect(document.body.contains(el)).toBe(false)
})
it('v2 auto-cleanup only removes the element registered via addDOMWidget, not unrelated elements', () => {
excluded('v2 auto-cleanup only removes the element registered via addDOMWidget, not unrelated elements', () => {
const registeredEl = makeDiv()
const unrelatedEl = makeDiv()
document.body.appendChild(registeredEl)

View File

@@ -1,11 +1,38 @@
// Category: BC.05 — Custom DOM widgets and node sizing
// DB cross-ref: S4.W2, S2.N11
// Exemplar: https://github.com/Lightricks/ComfyUI-LTXVideo/blob/main/web/js/sparse_track_editor.js#L218
// compat-floor: blast_radius 5.45 ≥ 2.0 — MUST pass before v2 ships
// v2 replacement: NodeHandle.addDOMWidget(opts) — auto-hooks computeSize via WidgetHandle geometry
//
// AXIOM-EXCLUDED (wave-10, D-ban-runtime-addwidget, AXIOMS.md A15):
// v2 NodeHandle.addDOMWidget / addWidget surfaces removed. All tests in
// this file are wrapped with `axiomExcluded({...})` (vitest test.fails)
// and continue to run as regression alarms — if the v2 surface is
// ever re-introduced, these tests flip to FAIL.
//
// Migration paths for original consumers:
// - Declare in Python INPUT_TYPES
// - Boxed widget (e.g. BBOX [x,y,w,h])
// - Non-widget UI primitive via defineNode/defineExtension setup()
//
// The "compat-floor blast_radius ≥ 2.0 MUST pass before v2 ships"
// doctrine is retired (AXIOMS.md §Axiom-Excluded Test Annotation Policy).
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { axiomExcluded } from './helpers/axiomExcluded'
const excluded = axiomExcluded({
axiom: 'A15',
adr: 'decisions/D-ban-runtime-addwidget.md',
rationale:
'Widgets are schema-declared per A15; v2 NodeHandle does not expose addDOMWidget/addWidget.',
migration: [
'Declare in Python INPUT_TYPES',
'Boxed widget (e.g. BBOX [x,y,w,h])',
'Non-widget UI primitive via defineNode/defineExtension setup()'
],
restoration: 'D-ban-runtime-addwidget §Restoration criteria'
})
// ── Mock world (same pattern as bc-01.v2.test.ts) ────────────────────────────
// vi.hoisted factory runs before imports — keep handle creation inline.
@@ -41,12 +68,19 @@ import {
_clearExtensionsForTesting,
_setDispatchImplForTesting,
defineNode,
getDOMWidgetElement,
mountExtensionsForNode,
unmountExtensionsForNode
} from '@/services/extension-api-service'
import type { NodeEntityId, WidgetEntityId } from '@/world/entityIds'
// Stub for the removed `getDOMWidgetElement` export. The side table was
// deleted alongside the v2 addDOMWidget shim per D-ban-runtime-addwidget;
// tests that reference it remain (wrapped via axiomExcluded) so the
// resulting assertion failures continue to flag any re-introduction.
const getDOMWidgetElement = (
_widgetId: WidgetEntityId
): HTMLElement | undefined => undefined
// ── Helpers ───────────────────────────────────────────────────────────────────
function makeNodeId(n: number): NodeEntityId {
@@ -98,7 +132,7 @@ describe('BC.05 v2 contract — custom DOM widgets and node sizing', () => {
})
describe('NodeHandle.addDOMWidget(opts) — widget registration (S4.W2)', () => {
it('addDOMWidget dispatches a CreateWidget command with type "DOM" and the given name', () => {
excluded('addDOMWidget dispatches a CreateWidget command with type "DOM" and the given name', () => {
const el = makeDiv()
defineNode({
@@ -120,7 +154,7 @@ describe('BC.05 v2 contract — custom DOM widgets and node sizing', () => {
expect(createCmd?.widgetType).toBe('DOM')
})
it('addDOMWidget returns a WidgetHandle with the correct name', () => {
excluded('addDOMWidget returns a WidgetHandle with the correct name', () => {
let handleName: string | undefined
defineNode({
@@ -141,7 +175,7 @@ describe('BC.05 v2 contract — custom DOM widgets and node sizing', () => {
expect(handleName).toBe('preview')
})
it('addDOMWidget stores the DOM element in a side table (not in command options, for serializability)', () => {
excluded('addDOMWidget stores the DOM element in a side table (not in command options, for serializability)', () => {
const el = makeDiv()
let widgetId: WidgetEntityId | undefined
@@ -169,7 +203,7 @@ describe('BC.05 v2 contract — custom DOM widgets and node sizing', () => {
expect(getDOMWidgetElement(widgetId!)).toBe(el)
})
it('addDOMWidget uses the provided height option rather than offsetHeight when specified', () => {
excluded('addDOMWidget uses the provided height option rather than offsetHeight when specified', () => {
const el = makeDiv(120) // offsetHeight = 120
const customHeight = 250
@@ -195,7 +229,7 @@ describe('BC.05 v2 contract — custom DOM widgets and node sizing', () => {
expect(createCmd?.options.__domHeight).toBe(customHeight)
})
it('addDOMWidget falls back to element.offsetHeight when no height option is given', () => {
excluded('addDOMWidget falls back to element.offsetHeight when no height option is given', () => {
const el = makeDiv(88)
defineNode({
@@ -216,7 +250,7 @@ describe('BC.05 v2 contract — custom DOM widgets and node sizing', () => {
expect(createCmd?.options.__domHeight).toBe(88)
})
it('DOM element is removed from the document when the node scope is disposed', () => {
excluded('DOM element is removed from the document when the node scope is disposed', () => {
const el = makeDiv()
document.body.appendChild(el)
expect(document.body.contains(el)).toBe(true)
@@ -240,7 +274,7 @@ describe('BC.05 v2 contract — custom DOM widgets and node sizing', () => {
})
describe('WidgetHandle geometry — setHeight (replaces S2.N11 computeSize override)', () => {
it('WidgetHandle.setHeight dispatches a SetWidgetOption command with key "__domHeight"', () => {
excluded('WidgetHandle.setHeight dispatches a SetWidgetOption command with key "__domHeight"', () => {
defineNode({
name: 'bc05.v2.set-height',
nodeCreated(handle) {
@@ -266,7 +300,7 @@ describe('BC.05 v2 contract — custom DOM widgets and node sizing', () => {
expect(setCmd).toBeDefined()
})
it('multiple addDOMWidget calls each produce independent CreateWidget commands', () => {
excluded('multiple addDOMWidget calls each produce independent CreateWidget commands', () => {
defineNode({
name: 'bc05.v2.multi-widget',
nodeCreated(handle) {

View File

@@ -1,11 +1,32 @@
// Category: BC.11 — Widget imperative state writes
// DB cross-ref: S4.W4, S4.W5, S2.N16
// Exemplar: https://github.com/r-vage/ComfyUI_Eclipse/blob/main/js/eclipse-set-get.js#L9
// Migration: v1 direct property mutation (widget.value, widget.options.values, node.widgets.push/splice)
// → v2 WidgetHandle.setValue / setOption / NodeHandle.addWidget
//
// PARTIALLY AXIOM-EXCLUDED (wave-10, D-ban-runtime-addwidget, AXIOMS.md A15):
// - The widget.value→setValue and widget.options→setOption parity blocks
// remain unchanged (these v2 surfaces are valid).
// - The "node.widgets.push/splice → NodeHandle.addWidget" describe block
// is wrapped via `axiomExcluded({...})` (vitest test.fails) because the
// v2 surface no longer exposes addWidget. v1 callers migrate to one of —
// declare in INPUT_TYPES / boxed widget / non-widget UI primitive.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { axiomExcluded } from './helpers/axiomExcluded'
const excluded = axiomExcluded({
axiom: 'A15',
adr: 'decisions/D-ban-runtime-addwidget.md',
rationale:
'v2 NodeHandle does not expose addWidget; the v1↔v2 parity scenario this describes is no longer valid.',
migration: [
'Declare in Python INPUT_TYPES',
'Boxed widget (e.g. BBOX [x,y,w,h])',
'Non-widget UI primitive via defineNode/defineExtension setup()'
],
restoration: 'D-ban-runtime-addwidget §Restoration criteria'
})
// ── Mock world (same pattern as bc-01.migration.test.ts) ──────────────────────
// vi.hoisted factory runs before imports — keep handle creation inline.
@@ -250,7 +271,7 @@ describe('BC.11 migration — widget imperative state writes', () => {
})
describe('node.widgets.push/splice → NodeHandle.addWidget (S2.N16)', () => {
it('v1 push and v2 addWidget both result in a new widget with the expected name', () => {
excluded('v1 push and v2 addWidget both result in a new widget with the expected name', () => {
// v1: push into node.widgets
const v1Node = createV1Node()
const v1NewWidget = createV1Widget('dynamic_lora', '')
@@ -274,7 +295,7 @@ describe('BC.11 migration — widget imperative state writes', () => {
expect(v2Names).toContain('dynamic_lora')
})
it('v1 splice by index is position-dependent; v2 addWidget uses name-keyed identity (no drift)', () => {
excluded('v1 splice by index is position-dependent; v2 addWidget uses name-keyed identity (no drift)', () => {
// v1: positional splice — inserting before 'cfg' bumps 'cfg' index
const v1Node = createV1Node([
createV1Widget('steps', 20),
@@ -308,7 +329,7 @@ describe('BC.11 migration — widget imperative state writes', () => {
expect(names).toContain('new_widget')
})
it('v2 addWidget returns a WidgetHandle that can immediately call setValue — no index lookup needed', () => {
excluded('v2 addWidget returns a WidgetHandle that can immediately call setValue — no index lookup needed', () => {
defineNode({
name: 'bc11.mig.immediate-set',
nodeCreated(handle) {
@@ -326,7 +347,7 @@ describe('BC.11 migration — widget imperative state writes', () => {
expect(setCmd).toBeDefined()
})
it('v1 push requires manual index tracking; v2 addWidget returns handle directly — no index bookkeeping', () => {
excluded('v1 push requires manual index tracking; v2 addWidget returns handle directly — no index bookkeeping', () => {
// v1: to get the widget back after push, you track the index
const v1Node = createV1Node()
v1Node.widgets.push(createV1Widget('added', ''))

View File

@@ -1,12 +1,36 @@
// Category: BC.11 — Widget imperative state writes
// DB cross-ref: S4.W4, S4.W5, S2.N16
// Exemplar: https://github.com/r-vage/ComfyUI_Eclipse/blob/main/js/eclipse-set-get.js#L9
// blast_radius: 5.81 — compat-floor: blast_radius ≥ 2.0 — MUST pass before v2 ships
// v2 replacement: WidgetHandle.setValue(v), WidgetHandle.setOption(key,v), NodeHandle.addWidget(opts)
//
// PARTIALLY AXIOM-EXCLUDED (wave-10, D-ban-runtime-addwidget, AXIOMS.md A15):
// - WidgetHandle.setValue / setHidden / setDisabled / setOption tests
// remain unchanged — these surfaces are valid in v2.
// - The "NodeHandle.addWidget" describe block is wrapped via
// `axiomExcluded({...})` (vitest test.fails) because v2 NodeHandle
// no longer exposes `addWidget`. The tests continue to run as
// regression alarms.
//
// The "compat-floor blast_radius ≥ 2.0 MUST pass before v2 ships"
// doctrine is retired (AXIOMS.md §Axiom-Excluded Test Annotation Policy).
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { WidgetHandle } from '@/extension-api/widget'
import { axiomExcluded } from './helpers/axiomExcluded'
const excluded = axiomExcluded({
axiom: 'A15',
adr: 'decisions/D-ban-runtime-addwidget.md',
rationale:
'v2 NodeHandle does not expose addWidget; runtime widget addition is forbidden per A15.',
migration: [
'Declare in Python INPUT_TYPES',
'Boxed widget (e.g. BBOX [x,y,w,h])',
'Non-widget UI primitive via defineNode/defineExtension setup()'
],
restoration: 'D-ban-runtime-addwidget §Restoration criteria'
})
// ── Mock world (same pattern as bc-01.v2.test.ts) ────────────────────────────
// vi.hoisted factory runs before imports — keep handle creation inline.
@@ -251,7 +275,7 @@ describe('BC.11 v2 contract — widget imperative state writes', () => {
})
describe('NodeHandle.addWidget — managed widget list mutation (S2.N16)', () => {
it('addWidget dispatches a CreateWidget command and returns a handle with the given name', () => {
excluded('addWidget dispatches a CreateWidget command and returns a handle with the given name', () => {
let handleName: string | undefined
defineNode({
@@ -273,7 +297,7 @@ describe('BC.11 v2 contract — widget imperative state writes', () => {
expect(handleName).toBe('steps')
})
it('addWidget for each of two distinct widgets produces two independent CreateWidget commands', () => {
excluded('addWidget for each of two distinct widgets produces two independent CreateWidget commands', () => {
defineNode({
name: 'bc11.v2.add-two-widgets',
nodeCreated(handle) {
@@ -295,7 +319,7 @@ describe('BC.11 v2 contract — widget imperative state writes', () => {
expect(createCmds).toHaveLength(2)
})
it('addWidget carries the defaultValue in the CreateWidget command', () => {
excluded('addWidget carries the defaultValue in the CreateWidget command', () => {
defineNode({
name: 'bc11.v2.add-widget-default',
nodeCreated(handle) {

View File

@@ -59,7 +59,8 @@ export function createV2Runtime(options: V2RuntimeOptions = {}): V2Runtime {
function createHandle(record: NodeRecord): NodeHandle {
// Minimal NodeHandle stub — only the fields BC tests touch are real.
const widgets: Array<{ name: string; id: string }> = []
let widgetCounter = 0
// widgetCounter removed alongside addWidget/addDOMWidget mocks
// per D-ban-runtime-addwidget (wave-10).
return {
id: record.id as unknown as string,
@@ -83,48 +84,12 @@ export function createV2Runtime(options: V2RuntimeOptions = {}): V2Runtime {
setProperty: () => {},
widget: (name: string) => widgets.find((w) => w.name === name),
widgets: () => widgets,
addWidget: (_type: string, name: string, _defaultValue: unknown) => {
const id = `widget:${record.id}:${++widgetCounter}`
const wh = {
name,
id,
equals(other: { id: string }): boolean {
return this.id === other.id
},
getValue: () => undefined,
setValue: () => {},
setOption: () => {},
setHidden: () => {},
setDisabled: () => {},
setHeight: () => {},
on: () => () => {},
isSerializeEnabled: () => true,
setSerializeEnabled: () => {}
}
widgets.push(wh)
return wh
},
addDOMWidget: (opts: { name: string; element: HTMLElement }) => {
const id = `widget:${record.id}:dom:${++widgetCounter}`
const wh = {
name: opts.name,
id,
equals(other: { id: string }): boolean {
return this.id === other.id
},
getValue: () => undefined,
setValue: () => {},
setOption: () => {},
setHidden: () => {},
setDisabled: () => {},
setHeight: () => {},
on: () => () => {},
isSerializeEnabled: () => true,
setSerializeEnabled: () => {}
}
widgets.push(wh)
return wh
},
// REMOVED per AXIOMS.md A15 and
// decisions/D-ban-runtime-addwidget.md — v2 NodeHandle does not
// expose addWidget / addDOMWidget. These mocks would mask the
// absence of the public surface in harness-backed tests.
// addWidget: (_type, name, _defaultValue) => { … }
// addDOMWidget: (opts) => { … }
inputs: () => [],
outputs: () => [],
on: () => () => {}

View File

@@ -0,0 +1,94 @@
/**
* Axiom-Excluded Test Annotation Helper
*
* Per the Axiom-Excluded Test Annotation Policy
* (`AXIOMS.md §Axiom-Excluded Test Annotation Policy`, AGENTS.md Rule 11,
* `decisions/D-ban-runtime-addwidget.md §Testing policy`).
*
* When a source-code surface is removed/deferred per an accepted axiom or
* ADR, BC tests that asserted on the absent surface MUST be converted to
* this helper rather than deleted outright. The annotated test continues
* to run; if the surface is ever re-introduced (intentionally or by
* regression) the expected-failure flips to a real failure and surfaces
* the policy violation.
*
* Mechanism: wraps vitest `test.fails(...)` and writes structured
* metadata to `task.meta.annotations` (vitest 1.x+). Reporters and
* tooling can pivot on the annotation to surface a per-axiom dashboard.
*
* @example
* ```ts
* import { axiomExcluded } from './helpers/axiomExcluded'
*
* axiomExcluded({
* axiom: 'A15',
* adr: 'decisions/D-ban-runtime-addwidget.md',
* rationale: 'Widgets are schema-declared per A15; v2 NodeHandle does not expose addWidget/addDOMWidget.',
* migration: [
* 'Declare in INPUT_TYPES',
* 'Boxed widget (e.g. BBOX)',
* 'Non-widget UI primitive via bootstrap-hooks'
* ],
* restoration: 'D-ban-runtime-addwidget §Restoration criteria'
* })('addDOMWidget dispatches CreateWidget command', () => {
* // Original test body — asserts the absent surface.
* // Now expected to fail; test.fails converts the throw to a PASS.
* })
* ```
*
* @see AXIOMS.md §Axiom-Excluded Test Annotation Policy
* @see AGENTS.md Rule 11
* @see decisions/D-ban-runtime-addwidget.md
*/
import { test } from 'vitest'
export interface AxiomExcludedAnnotation {
/** Short axiom id, e.g. `'A15'` or `'A14:REMOVED'`. */
axiom: string
/** Workspace ADR path, e.g. `'decisions/D-ban-runtime-addwidget.md'`. */
adr: string
/** One-sentence summary of why the surface is absent. */
rationale: string
/** Migration paths the original consumer should take. */
migration: string[]
/** Optional cross-ref to ADR restoration criteria. */
restoration?: string
}
/**
* Returns a vitest test factory that registers expected-failure tests
* with structured axiom-exclusion annotations.
*
* The returned factory has the same call signature as `test.fails`:
* `(name, fn)` or `(name, fn, timeout)`.
*/
export function axiomExcluded(annotation: AxiomExcludedAnnotation) {
return function axiomExcludedTest(
name: string,
fn: () => void | Promise<void>,
timeout?: number
): void {
// test.fails: passes if fn throws, fails if fn succeeds — gives us the
// "regression alarm" semantics: if the absent surface is restored, the
// body's assertions stop throwing and this test starts failing.
test.fails(
name,
async (context) => {
// Attach the annotation to task.meta so reporters and tooling can
// pivot on it (vitest exposes task.meta on the runner task).
const task = (
context as unknown as {
task?: { meta?: Record<string, unknown> }
}
).task
if (task) {
task.meta = task.meta ?? {}
;(task.meta as Record<string, unknown>).axiomExcluded = annotation
}
await fn()
},
timeout
)
}
}