@object-ui/fields 17.1.0 → 17.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +786 -0
- package/dist/FieldEditWidget.d.ts +2 -2
- package/dist/__tests__/cell-truncation.test.d.ts +0 -0
- package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
- package/dist/__tests__/spec-symbol-batch7.test.d.ts +8 -0
- package/dist/__tests__/widget-aria-invalid-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-aria-invalid-registry-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-dom-leak-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-props-contract.test.d.ts +8 -0
- package/dist/index.cjs +5 -5
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2882 -2569
- package/dist/widgets/AddressField.d.ts +2 -2
- package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
- package/dist/widgets/AutoNumberField.d.ts +2 -2
- package/dist/widgets/AvatarField.d.ts +2 -2
- package/dist/widgets/BooleanField.d.ts +2 -2
- package/dist/widgets/CapabilityMultiSelectField.d.ts +2 -2
- package/dist/widgets/CharacterCount.d.ts +56 -0
- package/dist/widgets/CheckboxesField.d.ts +2 -2
- package/dist/widgets/CodeField.d.ts +2 -2
- package/dist/widgets/ColorField.d.ts +2 -2
- package/dist/widgets/CurrencyField.d.ts +2 -2
- package/dist/widgets/DateField.d.ts +2 -2
- package/dist/widgets/DateTimeField.d.ts +2 -2
- package/dist/widgets/EmailField.d.ts +2 -2
- package/dist/widgets/FileField.d.ts +2 -2
- package/dist/widgets/FilterConditionField.d.ts +2 -2
- package/dist/widgets/FormulaField.d.ts +2 -2
- package/dist/widgets/FullscreenFieldEditor.d.ts +155 -0
- package/dist/widgets/GeolocationField.d.ts +2 -2
- package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
- package/dist/widgets/GridField.d.ts +2 -2
- package/dist/widgets/ImageField.d.ts +2 -2
- package/dist/widgets/LocationField.d.ts +2 -2
- package/dist/widgets/LookupField.d.ts +2 -2
- package/dist/widgets/LookupField.gateHintLabel.test.d.ts +8 -0
- package/dist/widgets/MasterDetailField.d.ts +2 -2
- package/dist/widgets/MultiSelectField.d.ts +2 -2
- package/dist/widgets/NumberField.d.ts +2 -2
- package/dist/widgets/ObjectField.d.ts +2 -2
- package/dist/widgets/ObjectRefField.d.ts +2 -2
- package/dist/widgets/OptionsEmptyState.d.ts +45 -0
- package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
- package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
- package/dist/widgets/PasswordField.d.ts +2 -2
- package/dist/widgets/PercentField.d.ts +2 -2
- package/dist/widgets/PhoneField.d.ts +2 -2
- package/dist/widgets/QRCodeField.d.ts +2 -2
- package/dist/widgets/RadioField.d.ts +2 -2
- package/dist/widgets/RatingField.d.ts +2 -2
- package/dist/widgets/RecipientPickerField.d.ts +2 -2
- package/dist/widgets/RecordPickerDialog.d.ts +17 -1
- package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +8 -0
- package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +8 -0
- package/dist/widgets/RichTextField.d.ts +20 -2
- package/dist/widgets/SelectField.d.ts +2 -2
- package/dist/widgets/SignatureField.d.ts +2 -2
- package/dist/widgets/SliderField.d.ts +2 -2
- package/dist/widgets/SummaryField.d.ts +2 -2
- package/dist/widgets/TagsField.d.ts +2 -2
- package/dist/widgets/TagsField.placeholder.no-provider.test.d.ts +0 -0
- package/dist/widgets/TagsField.placeholder.test.d.ts +0 -0
- package/dist/widgets/TextAreaField.d.ts +45 -6
- package/dist/widgets/TextField.d.ts +2 -2
- package/dist/widgets/TimeField.d.ts +2 -2
- package/dist/widgets/UrlField.d.ts +2 -2
- package/dist/widgets/UserField.d.ts +2 -2
- package/dist/widgets/VectorField.d.ts +2 -2
- package/dist/widgets/__tests__/FullscreenFieldEditor.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/FullscreenFieldEditor.no-provider.test.d.ts +0 -0
- package/dist/widgets/__tests__/RichTextField.fullscreenDisabled.test.d.ts +0 -0
- package/dist/widgets/__tests__/RichTextField.mobileFullscreen.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.announcements.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.no-provider.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenDisabled.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.mobileFullscreen.test.d.ts +0 -0
- package/dist/widgets/file-value.d.ts +20 -33
- package/dist/widgets/toDomProps.d.ts +91 -0
- package/dist/widgets/types.d.ts +215 -4
- package/dist/withFieldCarrier.d.ts +56 -0
- package/package.json +13 -13
- package/src/FieldEditWidget.tsx +6 -4
- package/src/__tests__/cell-truncation.test.tsx +96 -0
- package/src/__tests__/field-carrier-sdui.test.tsx +169 -0
- package/src/__tests__/spec-symbol-batch7.test.ts +126 -0
- package/src/__tests__/validation-feedback.test.tsx +22 -13
- package/src/__tests__/widget-aria-invalid-e2e.test.tsx +265 -0
- package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +338 -0
- package/src/__tests__/widget-dom-leak-e2e.test.tsx +598 -0
- package/src/__tests__/widget-props-contract.test.tsx +142 -0
- package/src/complex-widgets.test.tsx +3 -3
- package/src/datetime-widgets.test.tsx +2 -2
- package/src/index.tsx +77 -27
- package/src/widgets/AddressField.tsx +32 -13
- package/src/widgets/AddressField.uniqueIds.test.tsx +92 -0
- package/src/widgets/AutoNumberField.tsx +2 -2
- package/src/widgets/AvatarField.tsx +10 -3
- package/src/widgets/BooleanField.tsx +5 -5
- package/src/widgets/CapabilityMultiSelectField.tsx +4 -4
- package/src/widgets/CharacterCount.tsx +263 -0
- package/src/widgets/CheckboxesField.tsx +35 -18
- package/src/widgets/CodeField.tsx +10 -3
- package/src/widgets/ColorField.tsx +12 -3
- package/src/widgets/CurrencyField.tsx +6 -5
- package/src/widgets/DateField.tsx +4 -4
- package/src/widgets/DateTimeField.tsx +4 -4
- package/src/widgets/EmailField.tsx +6 -6
- package/src/widgets/FileField.tsx +14 -4
- package/src/widgets/FilterConditionField.tsx +14 -3
- package/src/widgets/FormulaField.tsx +3 -3
- package/src/widgets/FullscreenFieldEditor.tsx +271 -0
- package/src/widgets/GeolocationField.tsx +22 -7
- package/src/widgets/GeolocationField.uniqueIds.test.tsx +88 -0
- package/src/widgets/GridField.tsx +4 -4
- package/src/widgets/ImageField.tsx +10 -3
- package/src/widgets/LocationField.tsx +10 -3
- package/src/widgets/LookupField.gateHintLabel.test.tsx +135 -0
- package/src/widgets/LookupField.tsx +50 -15
- package/src/widgets/MasterDetailField.tsx +3 -3
- package/src/widgets/MultiSelectField.tsx +34 -18
- package/src/widgets/NumberField.tsx +5 -5
- package/src/widgets/ObjectField.tsx +18 -9
- package/src/widgets/ObjectRefField.tsx +14 -4
- package/src/widgets/OptionsEmptyState.no-provider.test.tsx +88 -0
- package/src/widgets/OptionsEmptyState.test.tsx +169 -0
- package/src/widgets/OptionsEmptyState.tsx +83 -0
- package/src/widgets/PasswordField.tsx +5 -5
- package/src/widgets/PeoplePicker.tsx +7 -1
- package/src/widgets/PercentField.tsx +6 -5
- package/src/widgets/PhoneField.tsx +6 -6
- package/src/widgets/QRCodeField.tsx +10 -3
- package/src/widgets/RadioField.tsx +28 -18
- package/src/widgets/RatingField.tsx +19 -4
- package/src/widgets/RecipientPickerField.tsx +22 -4
- package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +611 -0
- package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +112 -0
- package/src/widgets/RecordPickerDialog.tsx +173 -8
- package/src/widgets/RichTextField.tsx +152 -25
- package/src/widgets/SelectField.tsx +53 -22
- package/src/widgets/SignatureField.tsx +2 -2
- package/src/widgets/SliderField.tsx +3 -3
- package/src/widgets/SummaryField.tsx +3 -3
- package/src/widgets/TagsField.placeholder.no-provider.test.tsx +68 -0
- package/src/widgets/TagsField.placeholder.test.tsx +95 -0
- package/src/widgets/TagsField.tsx +25 -4
- package/src/widgets/TextAreaField.tsx +172 -94
- package/src/widgets/TextField.tsx +5 -5
- package/src/widgets/TimeField.tsx +4 -4
- package/src/widgets/UrlField.tsx +6 -6
- package/src/widgets/UserField.tsx +3 -3
- package/src/widgets/VectorField.tsx +3 -3
- package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +248 -0
- package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +116 -0
- package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +244 -0
- package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +195 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +417 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +287 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +160 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +161 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +346 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +246 -0
- package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +175 -0
- package/src/widgets/file-value.ts +21 -5
- package/src/widgets/toDomProps.ts +175 -0
- package/src/widgets/types.ts +230 -7
- package/src/widgets/useFieldTranslation.ts +60 -0
- package/src/withFieldCarrier.tsx +75 -0
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* GATE: no field widget may leak a non-DOM prop onto a DOM element
|
|
11
|
+
* (objectui#3291).
|
|
12
|
+
*
|
|
13
|
+
* Every registered field widget is rendered through BOTH hosts, and every
|
|
14
|
+
* attribute of every element it produced is checked against what HTML actually
|
|
15
|
+
* defines. An attribute nobody can explain is a leak.
|
|
16
|
+
*
|
|
17
|
+
* ## What this catches that a unit test cannot
|
|
18
|
+
*
|
|
19
|
+
* The leak was never in one widget. It was structural: a widget spread
|
|
20
|
+
* `{...props}` onto its control, and the hosts hand a widget more than the DOM
|
|
21
|
+
* can take. Measured on `origin/main`, a real form, a real widget:
|
|
22
|
+
*
|
|
23
|
+
* ```
|
|
24
|
+
* <input placeholder="PH-f" zzcanary="CANARY-STR" zzcanaryobj="[object Object]"
|
|
25
|
+
* zzcanarynum="42" id="…" type="text" value="" name="f">
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* `zzcanaryobj="[object Object]"` is an ordinary key an author wrote on the
|
|
29
|
+
* field config. That is why the fix is a WHITELIST (`toDomProps`) and why this
|
|
30
|
+
* test plants canaries rather than checking a list of known-bad names: a
|
|
31
|
+
* blacklist of today's renderer props would pass all three canaries above.
|
|
32
|
+
*
|
|
33
|
+
* ## Four things that silently defeat a test like this
|
|
34
|
+
*
|
|
35
|
+
* 1. **React warnings prove nothing.** React 19 passes an all-lowercase
|
|
36
|
+
* unknown attribute through in COMPLETE silence. In the audit sweep the
|
|
37
|
+
* only warning came from a camelCase canary — which was written to the DOM
|
|
38
|
+
* anyway. So this walks real DOM attributes and never listens for console
|
|
39
|
+
* output.
|
|
40
|
+
* 2. **Both hosts, or the result is a false pass.** The form renderer strips a
|
|
41
|
+
* known set before forwarding; `SchemaRenderer` spreads the whole authored
|
|
42
|
+
* node with NO strip layer, so it leaks strictly more (`label` is only
|
|
43
|
+
* visible there). On that path the widget's own spread is the only defence.
|
|
44
|
+
* 3. **An error variant that produces no error tests nothing.** `required` +
|
|
45
|
+
* a `false` boolean does NOT fail here — this repo made `required` a
|
|
46
|
+
* presence check, so `false` is a value (cloud#972). The audit's first pass
|
|
47
|
+
* under-reported the `error` leak by one widget for exactly this reason.
|
|
48
|
+
* Every error variant therefore ASSERTS the message rendered before it
|
|
49
|
+
* scans; a variant that silently produces no error fails the test.
|
|
50
|
+
* 4. **This repo runs happy-dom, not jsdom** (`vitest.config.mts`), whose IDL
|
|
51
|
+
* coverage has real gaps — `select[size]`, `option[label]`, `textarea[wrap]`
|
|
52
|
+
* and `col[span]` are all standard HTML that happy-dom does not reflect.
|
|
53
|
+
* See {@link isKnownAttribute} for how the judge is built, and
|
|
54
|
+
* {@link HAPPY_DOM_IDL_GAPS} for each measured exception and its reason.
|
|
55
|
+
* Every one of those four was found BY the calibration fixture below, not
|
|
56
|
+
* by guesswork.
|
|
57
|
+
*
|
|
58
|
+
* ## The judge proves itself
|
|
59
|
+
*
|
|
60
|
+
* Two fixtures run before the sweep: standard markup that must yield ZERO
|
|
61
|
+
* findings, and markup with planted fake attributes that must ALL be found.
|
|
62
|
+
* When a happy-dom upgrade changes IDL coverage, those fail loudly instead of
|
|
63
|
+
* the sweep going quietly blind.
|
|
64
|
+
*
|
|
65
|
+
* ## Deliberate coverage boundary
|
|
66
|
+
*
|
|
67
|
+
* Popovers are NOT opened. Radix needs pointer-capture APIs happy-dom does not
|
|
68
|
+
* implement. Every widget's props spread lands on its inline control (the
|
|
69
|
+
* trigger for a picker), which always renders — so the spread site IS covered,
|
|
70
|
+
* but content that exists only inside an open dropdown is NOT scanned by this
|
|
71
|
+
* test.
|
|
72
|
+
*
|
|
73
|
+
* Widgets are registered from STATIC imports wrapped in `withFieldCarrier`,
|
|
74
|
+
* never via `registerAllFields()`, which wraps every loader in `React.lazy` —
|
|
75
|
+
* an unbounded module load inside a bounded `waitFor` is this repo's known
|
|
76
|
+
* flake generator (AGENTS.md 测试纪律 / objectui#3010).
|
|
77
|
+
*/
|
|
78
|
+
|
|
79
|
+
import type { ComponentType } from 'react';
|
|
80
|
+
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest';
|
|
81
|
+
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
|
|
82
|
+
import '@testing-library/jest-dom';
|
|
83
|
+
import { ComponentRegistry } from '@object-ui/core';
|
|
84
|
+
// Module scope: pulls in the form renderer's registration side effect.
|
|
85
|
+
import '@object-ui/components';
|
|
86
|
+
import { SchemaRenderer } from '@object-ui/react';
|
|
87
|
+
|
|
88
|
+
import { withFieldCarrier } from '../withFieldCarrier';
|
|
89
|
+
import { FORM_FIELD_TYPES } from '../index';
|
|
90
|
+
|
|
91
|
+
import { TextField } from '../widgets/TextField';
|
|
92
|
+
import { TextAreaField } from '../widgets/TextAreaField';
|
|
93
|
+
import { NumberField } from '../widgets/NumberField';
|
|
94
|
+
import { BooleanField } from '../widgets/BooleanField';
|
|
95
|
+
import { SelectField } from '../widgets/SelectField';
|
|
96
|
+
import { DateField } from '../widgets/DateField';
|
|
97
|
+
import { DateTimeField } from '../widgets/DateTimeField';
|
|
98
|
+
import { TimeField } from '../widgets/TimeField';
|
|
99
|
+
import { EmailField } from '../widgets/EmailField';
|
|
100
|
+
import { PhoneField } from '../widgets/PhoneField';
|
|
101
|
+
import { UrlField } from '../widgets/UrlField';
|
|
102
|
+
import { MultiSelectField } from '../widgets/MultiSelectField';
|
|
103
|
+
import { RadioField } from '../widgets/RadioField';
|
|
104
|
+
import { CheckboxesField } from '../widgets/CheckboxesField';
|
|
105
|
+
import { TagsField } from '../widgets/TagsField';
|
|
106
|
+
import { CurrencyField } from '../widgets/CurrencyField';
|
|
107
|
+
import { PercentField } from '../widgets/PercentField';
|
|
108
|
+
import { PasswordField } from '../widgets/PasswordField';
|
|
109
|
+
import { RichTextField } from '../widgets/RichTextField';
|
|
110
|
+
import { LookupField } from '../widgets/LookupField';
|
|
111
|
+
import { FileField } from '../widgets/FileField';
|
|
112
|
+
import { ImageField } from '../widgets/ImageField';
|
|
113
|
+
import { LocationField } from '../widgets/LocationField';
|
|
114
|
+
import { FormulaField } from '../widgets/FormulaField';
|
|
115
|
+
import { SummaryField } from '../widgets/SummaryField';
|
|
116
|
+
import { AutoNumberField } from '../widgets/AutoNumberField';
|
|
117
|
+
import { UserField } from '../widgets/UserField';
|
|
118
|
+
import { ObjectField } from '../widgets/ObjectField';
|
|
119
|
+
import { VectorField } from '../widgets/VectorField';
|
|
120
|
+
import { GridField } from '../widgets/GridField';
|
|
121
|
+
import { ColorField } from '../widgets/ColorField';
|
|
122
|
+
import { SliderField } from '../widgets/SliderField';
|
|
123
|
+
import { RatingField } from '../widgets/RatingField';
|
|
124
|
+
import { CodeField } from '../widgets/CodeField';
|
|
125
|
+
import { AvatarField } from '../widgets/AvatarField';
|
|
126
|
+
import { AddressField } from '../widgets/AddressField';
|
|
127
|
+
import { GeolocationField } from '../widgets/GeolocationField';
|
|
128
|
+
import { SignatureField } from '../widgets/SignatureField';
|
|
129
|
+
import { QRCodeField } from '../widgets/QRCodeField';
|
|
130
|
+
import { ObjectRefField } from '../widgets/ObjectRefField';
|
|
131
|
+
import { FilterConditionField } from '../widgets/FilterConditionField';
|
|
132
|
+
import { RecipientPickerField } from '../widgets/RecipientPickerField';
|
|
133
|
+
|
|
134
|
+
/* ════════════════════════════════════════════════════════════════════════════
|
|
135
|
+
* The judge: is this attribute one HTML actually defines?
|
|
136
|
+
* ══════════════════════════════════════════════════════════════════════════ */
|
|
137
|
+
|
|
138
|
+
/** Open families. `data-*` is the one open family the widget contract declares. */
|
|
139
|
+
const OPEN_PREFIXES = [
|
|
140
|
+
'data-',
|
|
141
|
+
'aria-',
|
|
142
|
+
// `cmdk-root` / `cmdk-input` / `cmdk-list` … are marks the cmdk library puts
|
|
143
|
+
// on ITS OWN DOM. Not prop pass-through, and present on every cmdk-based
|
|
144
|
+
// picker (`object-ref`, `lookup`, …) the moment it renders.
|
|
145
|
+
'cmdk-',
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Global HTML attributes. Most are also IDL properties and would be caught by
|
|
150
|
+
* the reflection check below; they are listed because a missing IDL for a
|
|
151
|
+
* genuinely global attribute would otherwise read as a leak.
|
|
152
|
+
*/
|
|
153
|
+
const GLOBAL_HTML_ATTRIBUTES = new Set([
|
|
154
|
+
'id', 'class', 'style', 'title', 'lang', 'dir', 'hidden', 'tabindex', 'role',
|
|
155
|
+
'slot', 'part', 'exportparts', 'itemid', 'itemprop', 'itemref', 'itemscope',
|
|
156
|
+
'itemtype', 'translate', 'draggable', 'spellcheck', 'autocapitalize',
|
|
157
|
+
'autocorrect', 'contenteditable', 'enterkeyhint', 'inputmode', 'accesskey',
|
|
158
|
+
'nonce', 'is', 'popover', 'inert', 'autofocus',
|
|
159
|
+
]);
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Attributes whose IDL property is spelled differently enough that the
|
|
163
|
+
* case-insensitive reflection match below cannot find them.
|
|
164
|
+
*/
|
|
165
|
+
const ATTRIBUTE_TO_IDL_ALIAS: Record<string, string> = {
|
|
166
|
+
'class': 'className',
|
|
167
|
+
'for': 'htmlFor',
|
|
168
|
+
'accept-charset': 'acceptCharset',
|
|
169
|
+
'http-equiv': 'httpEquiv',
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* MEASURED gaps in happy-dom's IDL, kept deliberately tiny — each entry is an
|
|
174
|
+
* attribute HTML defines that happy-dom's element does not reflect as a
|
|
175
|
+
* property, so reflection alone would report it as a leak.
|
|
176
|
+
*/
|
|
177
|
+
const HAPPY_DOM_IDL_GAPS: Record<string, Set<string>> = {
|
|
178
|
+
// `HTMLSelectElement.size` is standard; happy-dom does not define it.
|
|
179
|
+
select: new Set(['size']),
|
|
180
|
+
// `HTMLOptionElement.label` is standard; happy-dom does not define it.
|
|
181
|
+
option: new Set(['label']),
|
|
182
|
+
// `HTMLTextAreaElement.wrap` is standard; happy-dom does not define it.
|
|
183
|
+
textarea: new Set(['wrap']),
|
|
184
|
+
// `HTMLTableColElement.span` is standard; happy-dom does not define it.
|
|
185
|
+
col: new Set(['span']),
|
|
186
|
+
colgroup: new Set(['span']),
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* SVG needs its own list: the reflection trick does NOT hold for SVG under
|
|
191
|
+
* happy-dom (`SVGElement` reflects almost nothing), and lucide icons put a
|
|
192
|
+
* fixed set of presentation attributes on every icon they render.
|
|
193
|
+
*/
|
|
194
|
+
const SVG_ATTRIBUTES = new Set([
|
|
195
|
+
'xmlns', 'xmlns:xlink', 'version', 'viewbox', 'preserveaspectratio',
|
|
196
|
+
'width', 'height', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'cx', 'cy', 'r', 'rx',
|
|
197
|
+
'ry', 'd', 'points', 'transform', 'fill', 'fill-rule', 'fill-opacity',
|
|
198
|
+
'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin',
|
|
199
|
+
'stroke-dasharray', 'stroke-dashoffset', 'stroke-opacity', 'opacity',
|
|
200
|
+
'clip-path', 'clip-rule', 'mask', 'offset', 'stop-color', 'stop-opacity',
|
|
201
|
+
'gradientunits', 'gradienttransform', 'patternunits', 'text-anchor',
|
|
202
|
+
'dominant-baseline', 'font-size', 'font-family', 'font-weight', 'vector-effect',
|
|
203
|
+
'shape-rendering', 'focusable', 'overflow', 'color',
|
|
204
|
+
]);
|
|
205
|
+
|
|
206
|
+
/** Lowercased IDL property names on a tag's prototype chain, cached per tag. */
|
|
207
|
+
const idlCache = new Map<string, Set<string>>();
|
|
208
|
+
|
|
209
|
+
function idlPropertiesFor(tagName: string): Set<string> {
|
|
210
|
+
const tag = tagName.toLowerCase();
|
|
211
|
+
const cached = idlCache.get(tag);
|
|
212
|
+
if (cached) return cached;
|
|
213
|
+
|
|
214
|
+
const names = new Set<string>();
|
|
215
|
+
const element = document.createElement(tag);
|
|
216
|
+
for (const own of Object.getOwnPropertyNames(element)) names.add(own.toLowerCase());
|
|
217
|
+
for (
|
|
218
|
+
let proto = Object.getPrototypeOf(element);
|
|
219
|
+
proto && proto !== Object.prototype;
|
|
220
|
+
proto = Object.getPrototypeOf(proto)
|
|
221
|
+
) {
|
|
222
|
+
for (const name of Object.getOwnPropertyNames(proto)) names.add(name.toLowerCase());
|
|
223
|
+
}
|
|
224
|
+
idlCache.set(tag, names);
|
|
225
|
+
return names;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The rule: an attribute is legitimate when HTML/SVG defines it for that
|
|
230
|
+
* element, or when it belongs to an open family.
|
|
231
|
+
*
|
|
232
|
+
* The reflection check ("does the element's prototype chain carry a property
|
|
233
|
+
* with this name, case-insensitively?") is what makes this maintainable — it
|
|
234
|
+
* covers `readonly→readOnly`, `maxlength→maxLength`, `colspan→colSpan` and
|
|
235
|
+
* every other per-tag attribute automatically, instead of a hand-kept table
|
|
236
|
+
* per element type that would rot.
|
|
237
|
+
*/
|
|
238
|
+
function isKnownAttribute(element: Element, attribute: string): boolean {
|
|
239
|
+
const name = attribute.toLowerCase();
|
|
240
|
+
|
|
241
|
+
if (OPEN_PREFIXES.some((prefix) => name.startsWith(prefix))) return true;
|
|
242
|
+
|
|
243
|
+
// Inline event handlers (`onclick`) reflect as IDL properties on every
|
|
244
|
+
// element; React never emits them as attributes, so reaching one means a
|
|
245
|
+
// handler-shaped prop was stringified onto the DOM. Treat as a leak.
|
|
246
|
+
if (name.startsWith('on')) return false;
|
|
247
|
+
|
|
248
|
+
const tag = element.tagName.toLowerCase();
|
|
249
|
+
|
|
250
|
+
if (element.namespaceURI === 'http://www.w3.org/2000/svg') {
|
|
251
|
+
return SVG_ATTRIBUTES.has(name) || GLOBAL_HTML_ATTRIBUTES.has(name);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (GLOBAL_HTML_ATTRIBUTES.has(name)) return true;
|
|
255
|
+
if (HAPPY_DOM_IDL_GAPS[tag]?.has(name)) return true;
|
|
256
|
+
|
|
257
|
+
const idl = idlPropertiesFor(tag);
|
|
258
|
+
const alias = ATTRIBUTE_TO_IDL_ALIAS[name];
|
|
259
|
+
if (alias && idl.has(alias.toLowerCase())) return true;
|
|
260
|
+
return idl.has(name);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
interface Leak {
|
|
264
|
+
tag: string;
|
|
265
|
+
attribute: string;
|
|
266
|
+
value: string;
|
|
267
|
+
outerHTML: string;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Every unexplained attribute on `root` and its descendants. */
|
|
271
|
+
function findLeaks(root: Element): Leak[] {
|
|
272
|
+
const leaks: Leak[] = [];
|
|
273
|
+
const elements: Element[] = [root, ...Array.from(root.querySelectorAll('*'))];
|
|
274
|
+
for (const element of elements) {
|
|
275
|
+
for (const attribute of Array.from(element.attributes)) {
|
|
276
|
+
if (isKnownAttribute(element, attribute.name)) continue;
|
|
277
|
+
leaks.push({
|
|
278
|
+
tag: element.tagName.toLowerCase(),
|
|
279
|
+
attribute: attribute.name,
|
|
280
|
+
value: attribute.value,
|
|
281
|
+
outerHTML: element.outerHTML.slice(0, 400),
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return leaks;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Renders the finding as the assertion's "actual" value, so a failure names
|
|
290
|
+
* the widget, the element, the attribute, its value and the markup. A 46-widget
|
|
291
|
+
* sweep failing as `expected [] to equal [ …47 items ]` is unusable.
|
|
292
|
+
*/
|
|
293
|
+
function leakReport(widgetType: string, variant: string, leaks: Leak[]): string {
|
|
294
|
+
if (leaks.length === 0) return '';
|
|
295
|
+
const lines = leaks.map(
|
|
296
|
+
(leak) =>
|
|
297
|
+
` <${leak.tag}> leaked ${leak.attribute}="${leak.value}"\n` +
|
|
298
|
+
` in: ${leak.outerHTML}`,
|
|
299
|
+
);
|
|
300
|
+
return (
|
|
301
|
+
`field:${widgetType} [${variant}] leaked ${leaks.length} non-DOM ` +
|
|
302
|
+
`attribute(s):\n${lines.join('\n')}`
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/* ════════════════════════════════════════════════════════════════════════════
|
|
307
|
+
* The judge proves itself, BEFORE it is trusted on 46 widgets
|
|
308
|
+
* ══════════════════════════════════════════════════════════════════════════ */
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Ordinary, correct markup. Every attribute here is one HTML defines, several
|
|
312
|
+
* chosen precisely because their IDL name differs from the attribute
|
|
313
|
+
* (`readonly`/`maxlength`/`colspan`/`class`/`for`), plus the two happy-dom IDL
|
|
314
|
+
* gaps (`select[size]`, `option[label]`) and a cmdk mark.
|
|
315
|
+
*/
|
|
316
|
+
const CLEAN_FIXTURE = `
|
|
317
|
+
<div id="root" class="a b" title="t" role="group" tabindex="0" hidden
|
|
318
|
+
data-testid="x" aria-label="l" data-state="open">
|
|
319
|
+
<input type="text" name="n" value="v" placeholder="p" readonly maxlength="5"
|
|
320
|
+
minlength="1" required autofocus autocomplete="off" inputmode="text"
|
|
321
|
+
step="1" min="0" max="9" pattern=".*" list="dl" />
|
|
322
|
+
<textarea rows="3" cols="4" wrap="soft" readonly></textarea>
|
|
323
|
+
<select size="2" multiple><option label="L" value="v" selected></option></select>
|
|
324
|
+
<button type="button" disabled formnovalidate value="on">b</button>
|
|
325
|
+
<a href="#" target="_blank" rel="noopener" download hreflang="en"></a>
|
|
326
|
+
<label for="root">l</label>
|
|
327
|
+
<img src="s.png" alt="a" width="10" height="10" loading="lazy" decoding="async" />
|
|
328
|
+
<table><colgroup><col span="2" /></colgroup><tbody><tr>
|
|
329
|
+
<td colspan="2" rowspan="1" headers="h"></td></tr></tbody></table>
|
|
330
|
+
<div cmdk-root=""><div cmdk-list=""></div></div>
|
|
331
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"
|
|
332
|
+
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
|
333
|
+
stroke-linejoin="round" class="icon"><path d="M0 0h24v24H0z" /></svg>
|
|
334
|
+
</div>
|
|
335
|
+
`;
|
|
336
|
+
|
|
337
|
+
/** Every planted attribute here MUST be reported. */
|
|
338
|
+
const PLANTED_LEAKS: ReadonlyArray<readonly [string, string]> = [
|
|
339
|
+
// The renderer-only props that reached the DOM in the audit.
|
|
340
|
+
['schema', '[object Object]'],
|
|
341
|
+
['error', 'Title is required'],
|
|
342
|
+
['emptyhint', 'Select country first'],
|
|
343
|
+
['datasource', '[object Object]'],
|
|
344
|
+
['dependentvalues', '[object Object]'],
|
|
345
|
+
['dependson', 'country'],
|
|
346
|
+
['inputtype', 'text'],
|
|
347
|
+
['compact', 'true'],
|
|
348
|
+
['onselectrecord', 'function'],
|
|
349
|
+
// The SDUI-only extra.
|
|
350
|
+
['label', 'Title'],
|
|
351
|
+
// The open tail: arbitrary keys an author wrote on the field config.
|
|
352
|
+
['zzcanary', 'CANARY-STR'],
|
|
353
|
+
['zzcanaryobj', '[object Object]'],
|
|
354
|
+
['zzcanarynum', '42'],
|
|
355
|
+
['zzcanarycamel', 'CANARY-CAMEL'],
|
|
356
|
+
['reference_to', 'contacts'],
|
|
357
|
+
];
|
|
358
|
+
|
|
359
|
+
describe('the leak judge is calibrated (objectui#3291)', () => {
|
|
360
|
+
it('reports NOTHING on standard markup — no false positives', () => {
|
|
361
|
+
const host = document.createElement('div');
|
|
362
|
+
host.innerHTML = CLEAN_FIXTURE;
|
|
363
|
+
document.body.appendChild(host);
|
|
364
|
+
try {
|
|
365
|
+
const leaks = findLeaks(host);
|
|
366
|
+
expect(
|
|
367
|
+
leaks.map((l) => `<${l.tag}> ${l.attribute}="${l.value}"`).join('\n'),
|
|
368
|
+
).toBe('');
|
|
369
|
+
} finally {
|
|
370
|
+
host.remove();
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('reports EVERY planted fake attribute — no false negatives', () => {
|
|
375
|
+
const host = document.createElement('div');
|
|
376
|
+
const planted = PLANTED_LEAKS.map(([name, value]) => `${name}="${value}"`).join(' ');
|
|
377
|
+
// On an <input>, so nothing can be excused by a permissive container.
|
|
378
|
+
host.innerHTML = `<input type="text" name="n" ${planted} />`;
|
|
379
|
+
document.body.appendChild(host);
|
|
380
|
+
try {
|
|
381
|
+
const found = new Set(findLeaks(host).map((leak) => leak.attribute));
|
|
382
|
+
const missed = PLANTED_LEAKS.map(([name]) => name).filter((name) => !found.has(name));
|
|
383
|
+
expect(missed).toEqual([]);
|
|
384
|
+
expect(found.size).toBe(PLANTED_LEAKS.length);
|
|
385
|
+
} finally {
|
|
386
|
+
host.remove();
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
/* ════════════════════════════════════════════════════════════════════════════
|
|
392
|
+
* Every registered field widget, both hosts
|
|
393
|
+
* ══════════════════════════════════════════════════════════════════════════ */
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Static components for the widget map's keys. Kept as one object so the
|
|
397
|
+
* parity assertion below can prove it covers the whole registry: a NEW field
|
|
398
|
+
* type added to `fieldWidgetMap` without a line here fails loudly rather than
|
|
399
|
+
* quietly going unscanned.
|
|
400
|
+
*/
|
|
401
|
+
const WIDGETS: Record<string, ComponentType<any>> = {
|
|
402
|
+
text: TextField,
|
|
403
|
+
textarea: TextAreaField,
|
|
404
|
+
number: NumberField,
|
|
405
|
+
boolean: BooleanField,
|
|
406
|
+
select: SelectField,
|
|
407
|
+
date: DateField,
|
|
408
|
+
datetime: DateTimeField,
|
|
409
|
+
time: TimeField,
|
|
410
|
+
email: EmailField,
|
|
411
|
+
phone: PhoneField,
|
|
412
|
+
url: UrlField,
|
|
413
|
+
multiselect: MultiSelectField,
|
|
414
|
+
radio: RadioField,
|
|
415
|
+
checkboxes: CheckboxesField,
|
|
416
|
+
tags: TagsField,
|
|
417
|
+
currency: CurrencyField,
|
|
418
|
+
percent: PercentField,
|
|
419
|
+
password: PasswordField,
|
|
420
|
+
markdown: RichTextField,
|
|
421
|
+
html: RichTextField,
|
|
422
|
+
richtext: RichTextField,
|
|
423
|
+
lookup: LookupField,
|
|
424
|
+
master_detail: LookupField,
|
|
425
|
+
file: FileField,
|
|
426
|
+
image: ImageField,
|
|
427
|
+
location: LocationField,
|
|
428
|
+
formula: FormulaField,
|
|
429
|
+
summary: SummaryField,
|
|
430
|
+
auto_number: AutoNumberField,
|
|
431
|
+
user: UserField,
|
|
432
|
+
owner: UserField,
|
|
433
|
+
object: ObjectField,
|
|
434
|
+
vector: VectorField,
|
|
435
|
+
grid: GridField,
|
|
436
|
+
color: ColorField,
|
|
437
|
+
slider: SliderField,
|
|
438
|
+
rating: RatingField,
|
|
439
|
+
code: CodeField,
|
|
440
|
+
avatar: AvatarField,
|
|
441
|
+
address: AddressField,
|
|
442
|
+
geolocation: GeolocationField,
|
|
443
|
+
signature: SignatureField,
|
|
444
|
+
qrcode: QRCodeField,
|
|
445
|
+
'object-ref': ObjectRefField,
|
|
446
|
+
'filter-condition': FilterConditionField,
|
|
447
|
+
'recipient-picker': RecipientPickerField,
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
/** Option widgets render an "unfillable" placeholder unless offered a list. */
|
|
451
|
+
const OPTION_TYPES = new Set(['select', 'multiselect', 'radio', 'checkboxes', 'tags']);
|
|
452
|
+
const OPTIONS = [
|
|
453
|
+
{ label: 'Alpha', value: 'alpha' },
|
|
454
|
+
{ label: 'Beta', value: 'beta' },
|
|
455
|
+
];
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* The value each variant starts from: `null` is MISSING for the required check
|
|
459
|
+
* this repo implements (presence, not truthiness — `false` and `0` are values,
|
|
460
|
+
* cloud#972), so one value drives a real validation failure for every type,
|
|
461
|
+
* including `boolean`. Trap 3 above is why that matters, and why every error
|
|
462
|
+
* variant asserts the message before it scans.
|
|
463
|
+
*/
|
|
464
|
+
const MISSING_VALUE = null;
|
|
465
|
+
|
|
466
|
+
/** Author-written extras — the open tail that a blacklist cannot close. */
|
|
467
|
+
const AUTHORED_EXTRAS = {
|
|
468
|
+
zzcanary: 'CANARY-STR',
|
|
469
|
+
zzcanaryobj: { nested: true },
|
|
470
|
+
zzcanarynum: 42,
|
|
471
|
+
zzcanaryCamel: 'CANARY-CAMEL',
|
|
472
|
+
reference_to: 'contacts',
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
function fieldConfig(type: string, extras: Record<string, unknown> = {}) {
|
|
476
|
+
return {
|
|
477
|
+
name: 'f',
|
|
478
|
+
label: 'F',
|
|
479
|
+
type: `field:${type}`,
|
|
480
|
+
...(OPTION_TYPES.has(type) ? { options: OPTIONS } : {}),
|
|
481
|
+
...extras,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function renderForm(field: Record<string, unknown>, required: boolean) {
|
|
486
|
+
const Form = ComponentRegistry.get('form')!;
|
|
487
|
+
return render(
|
|
488
|
+
<Form
|
|
489
|
+
schema={{
|
|
490
|
+
type: 'form',
|
|
491
|
+
mode: 'create',
|
|
492
|
+
showSubmit: true,
|
|
493
|
+
showCancel: false,
|
|
494
|
+
submitLabel: 'Create',
|
|
495
|
+
defaultValues: { f: MISSING_VALUE },
|
|
496
|
+
fields: [{ ...field, required }],
|
|
497
|
+
onSubmit: () => {},
|
|
498
|
+
}}
|
|
499
|
+
/>,
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* The form row. Waiting on `[data-field]` (emitted by `FormItem` for EVERY
|
|
505
|
+
* field) rather than widget-specific copy keeps the wait condition identical
|
|
506
|
+
* for all 46 widgets and independent of what any one of them renders.
|
|
507
|
+
*/
|
|
508
|
+
async function formRow(): Promise<Element> {
|
|
509
|
+
return waitFor(() => {
|
|
510
|
+
const row = document.querySelector('[data-field="f"]');
|
|
511
|
+
if (!row) throw new Error('field row never rendered');
|
|
512
|
+
return row;
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
beforeAll(() => {
|
|
517
|
+
for (const [type, Widget] of Object.entries(WIDGETS)) {
|
|
518
|
+
// `withFieldCarrier` is the real registration seam (objectui#3233) — going
|
|
519
|
+
// around it would test a widget in a shape no host ever produces.
|
|
520
|
+
ComponentRegistry.register(type, withFieldCarrier(Widget) as any, {
|
|
521
|
+
namespace: 'field',
|
|
522
|
+
skipFallback: true,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
}, 60000);
|
|
526
|
+
|
|
527
|
+
beforeEach(() => {
|
|
528
|
+
if (!(Element.prototype as any).scrollIntoView) {
|
|
529
|
+
(Element.prototype as any).scrollIntoView = () => {};
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
afterEach(() => {
|
|
534
|
+
cleanup();
|
|
535
|
+
vi.restoreAllMocks();
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
describe('no field widget leaks non-DOM props to the DOM (objectui#3291)', () => {
|
|
539
|
+
it('covers every field type the form can render', () => {
|
|
540
|
+
// The gate's reach. Without this, adding a widget adds an unscanned widget.
|
|
541
|
+
expect(Object.keys(WIDGETS).sort()).toEqual([...FORM_FIELD_TYPES].sort());
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
const types = Object.keys(WIDGETS);
|
|
545
|
+
|
|
546
|
+
it.each(types)('field:%s — form path, plain field', async (type) => {
|
|
547
|
+
renderForm(fieldConfig(type), false);
|
|
548
|
+
const row = await formRow();
|
|
549
|
+
expect(leakReport(type, 'form/plain', findLeaks(row))).toBe('');
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it.each(types)('field:%s — form path, author-written extra keys', async (type) => {
|
|
553
|
+
// The largest measured leak source: the form renderer destructures a fixed set
|
|
554
|
+
// of known keys and forwards the rest verbatim, so anything else an author
|
|
555
|
+
// put on the field config arrives at the widget as a prop.
|
|
556
|
+
renderForm(fieldConfig(type, AUTHORED_EXTRAS), false);
|
|
557
|
+
const row = await formRow();
|
|
558
|
+
expect(leakReport(type, 'form/authored-extras', findLeaks(row))).toBe('');
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it.each(types)('field:%s — form path, after a real validation failure', async (type) => {
|
|
562
|
+
renderForm(fieldConfig(type), true);
|
|
563
|
+
const row = await formRow();
|
|
564
|
+
|
|
565
|
+
fireEvent.click(screen.getByRole('button', { name: /create/i }));
|
|
566
|
+
|
|
567
|
+
// Trap 3: scan only once the error is PROVEN to exist. A variant that
|
|
568
|
+
// silently produced none would otherwise pass while testing nothing.
|
|
569
|
+
await waitFor(() => {
|
|
570
|
+
expect(document.querySelector('[data-field="f"]')?.textContent ?? '').toContain(
|
|
571
|
+
'is required',
|
|
572
|
+
);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
expect(leakReport(type, 'form/validation-error', findLeaks(await formRow()))).toBe('');
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it.each(types)('field:%s — SDUI path, plain node', async (type) => {
|
|
579
|
+
// Strictly wider than the form path: `SchemaRenderer` spreads the whole
|
|
580
|
+
// authored node as props with no strip layer, so the widget's own spread
|
|
581
|
+
// is the only defence here.
|
|
582
|
+
const { container } = render(
|
|
583
|
+
<SchemaRenderer schema={{ ...fieldConfig(type), type: `field:${type}` } as any} />,
|
|
584
|
+
);
|
|
585
|
+
await waitFor(() => expect(container.firstElementChild).toBeTruthy());
|
|
586
|
+
expect(leakReport(type, 'sdui/plain', findLeaks(container))).toBe('');
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it.each(types)('field:%s — SDUI path, author-written extra keys', async (type) => {
|
|
590
|
+
const { container } = render(
|
|
591
|
+
<SchemaRenderer
|
|
592
|
+
schema={{ ...fieldConfig(type, AUTHORED_EXTRAS), type: `field:${type}` } as any}
|
|
593
|
+
/>,
|
|
594
|
+
);
|
|
595
|
+
await waitFor(() => expect(container.firstElementChild).toBeTruthy());
|
|
596
|
+
expect(leakReport(type, 'sdui/authored-extras', findLeaks(container))).toBe('');
|
|
597
|
+
});
|
|
598
|
+
});
|