@exponential-at/ui 0.0.0-stage → 0.1.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/LICENSE +201 -0
- package/NOTICE +54 -0
- package/README.md +360 -2
- package/catalog/a11y.json +668 -0
- package/catalog/basic-map.json +104 -0
- package/catalog/code.json +58 -0
- package/catalog/core.catalog.json +4752 -0
- package/catalog/core.schema.json +22667 -0
- package/catalog/host.json +64 -0
- package/catalog/layout.json +20 -0
- package/catalog/limits.json +9 -0
- package/catalog/locale.json +48 -0
- package/catalog/macros.json +3417 -0
- package/catalog/recipes.json +61 -0
- package/catalog/strings.json +72 -0
- package/catalog/style.json +128 -0
- package/catalog/theme.schema.json +42543 -0
- package/catalog/tokens.json +47 -0
- package/conformance/README.md +217 -0
- package/conformance/baseline.ts +101 -0
- package/conformance/browser-page.ts +146 -0
- package/conformance/browser-suites.ts +683 -0
- package/conformance/compare.ts +201 -0
- package/conformance/dom.ts +96 -0
- package/conformance/dump.ts +163 -0
- package/conformance/fonts.json +154 -0
- package/conformance/manifest.json +224 -0
- package/conformance/parity-compare.ts +191 -0
- package/conformance/parity-report.schema.json +73 -0
- package/conformance/parity.ts +412 -0
- package/conformance/pixel.ts +177 -0
- package/conformance/primitives-page.ts +49 -0
- package/conformance/report.schema.json +66 -0
- package/conformance/run.ts +118 -0
- package/dist/chunks/index-98npjvsd.js +3940 -0
- package/dist/chunks/index-98npjvsd.js.map +27 -0
- package/dist/chunks/index-gwkt14w3.js +7314 -0
- package/dist/chunks/index-gwkt14w3.js.map +10 -0
- package/dist/chunks/index-njt7yzzm.js +134 -0
- package/dist/chunks/index-njt7yzzm.js.map +9 -0
- package/dist/chunks/index-we7va6v6.js +7984 -0
- package/dist/chunks/index-we7va6v6.js.map +15 -0
- package/dist/chunks/index-xrm6f7wc.js +2563 -0
- package/dist/chunks/index-xrm6f7wc.js.map +10 -0
- package/dist/conformance.js +182749 -0
- package/dist/conformance.js.map +10 -0
- package/dist/entries/prompt.js +251 -0
- package/dist/entries/prompt.js.map +11 -0
- package/dist/entries/schema.js +1725 -0
- package/dist/entries/schema.js.map +11 -0
- package/dist/host/index.js +122 -0
- package/dist/host/index.js.map +9 -0
- package/dist/index.js +3213 -0
- package/dist/index.js.map +27 -0
- package/dist/types/src/a11y.d.ts +21 -0
- package/dist/types/src/animation.d.ts +76 -0
- package/dist/types/src/basic-map.d.ts +38 -0
- package/dist/types/src/builder.d.ts +50 -0
- package/dist/types/src/catalog.d.ts +54 -0
- package/dist/types/src/catalog.described.d.ts +33 -0
- package/dist/types/src/catalog.generated.d.ts +829 -0
- package/dist/types/src/chart.d.ts +46 -0
- package/dist/types/src/code.d.ts +44 -0
- package/dist/types/src/color.d.ts +20 -0
- package/dist/types/src/conformance.d.ts +39 -0
- package/dist/types/src/connector/app.d.ts +14 -0
- package/dist/types/src/connector/connector.d.ts +77 -0
- package/dist/types/src/connector/index.d.ts +4 -0
- package/dist/types/src/css.d.ts +29 -0
- package/dist/types/src/define.d.ts +66 -0
- package/dist/types/src/direction.d.ts +16 -0
- package/dist/types/src/dynamic.d.ts +159 -0
- package/dist/types/src/entries/prompt.d.ts +11 -0
- package/dist/types/src/entries/schema.d.ts +8 -0
- package/dist/types/src/expr.d.ts +71 -0
- package/dist/types/src/extension.d.ts +4 -0
- package/dist/types/src/format.d.ts +112 -0
- package/dist/types/src/geometry.d.ts +40 -0
- package/dist/types/src/host/contract.d.ts +188 -0
- package/dist/types/src/host/decoders.d.ts +61 -0
- package/dist/types/src/host/index.d.ts +11 -0
- package/dist/types/src/host/package.d.ts +58 -0
- package/dist/types/src/host/policy.d.ts +105 -0
- package/dist/types/src/host/router.d.ts +34 -0
- package/dist/types/src/host/runtime.d.ts +205 -0
- package/dist/types/src/host/sources.d.ts +15 -0
- package/dist/types/src/host/transports.d.ts +109 -0
- package/dist/types/src/index.d.ts +63 -0
- package/dist/types/src/layout.d.ts +20 -0
- package/dist/types/src/limits.d.ts +21 -0
- package/dist/types/src/list.d.ts +133 -0
- package/dist/types/src/locale.d.ts +22 -0
- package/dist/types/src/macros.d.ts +29 -0
- package/dist/types/src/overlay.d.ts +27 -0
- package/dist/types/src/prompt.d.ts +44 -0
- package/dist/types/src/recipes.d.ts +17 -0
- package/dist/types/src/reducer.d.ts +41 -0
- package/dist/types/src/resizable.d.ts +38 -0
- package/dist/types/src/schema.d.ts +7 -0
- package/dist/types/src/strings.d.ts +13 -0
- package/dist/types/src/style.d.ts +201 -0
- package/dist/types/src/test-preload.d.ts +1 -0
- package/dist/types/src/text-shimmer.d.ts +24 -0
- package/dist/types/src/theme-types.d.ts +162 -0
- package/dist/types/src/theme.d.ts +73 -0
- package/dist/types/src/themes.d.ts +22 -0
- package/dist/types/src/tree-guides.d.ts +17 -0
- package/dist/types/src/types.d.ts +314 -0
- package/dist/types/src/validate.d.ts +31 -0
- package/dist/types/src/web/base-css.d.ts +1 -0
- package/dist/types/src/web/box-css.d.ts +55 -0
- package/dist/types/src/web/coerce.d.ts +32 -0
- package/dist/types/src/web/data.d.ts +105 -0
- package/dist/types/src/web/dates.d.ts +12 -0
- package/dist/types/src/web/form.d.ts +44 -0
- package/dist/types/src/web/host-types.d.ts +119 -0
- package/dist/types/src/web/index.d.ts +28 -0
- package/dist/types/src/web/inputs.d.ts +55 -0
- package/dist/types/src/web/list.d.ts +13 -0
- package/dist/types/src/web/listbox.d.ts +14 -0
- package/dist/types/src/web/markdown.d.ts +96 -0
- package/dist/types/src/web/media.d.ts +62 -0
- package/dist/types/src/web/parts.d.ts +4 -0
- package/dist/types/src/web/primitives/alert-dialog.d.ts +10 -0
- package/dist/types/src/web/primitives/alert.d.ts +9 -0
- package/dist/types/src/web/primitives/avatar-color.d.ts +2 -0
- package/dist/types/src/web/primitives/avatar.d.ts +13 -0
- package/dist/types/src/web/primitives/badge.d.ts +10 -0
- package/dist/types/src/web/primitives/breadcrumb.d.ts +10 -0
- package/dist/types/src/web/primitives/button.d.ts +6 -0
- package/dist/types/src/web/primitives/calendar.d.ts +43 -0
- package/dist/types/src/web/primitives/card.d.ts +9 -0
- package/dist/types/src/web/primitives/checkbox.d.ts +5 -0
- package/dist/types/src/web/primitives/choice-tiles.d.ts +32 -0
- package/dist/types/src/web/primitives/cn.d.ts +3 -0
- package/dist/types/src/web/primitives/code-block.d.ts +24 -0
- package/dist/types/src/web/primitives/command.d.ts +134 -0
- package/dist/types/src/web/primitives/context-ring.d.ts +24 -0
- package/dist/types/src/web/primitives/dialog-arms.d.ts +6 -0
- package/dist/types/src/web/primitives/dialog.d.ts +24 -0
- package/dist/types/src/web/primitives/disclosure-header.d.ts +5 -0
- package/dist/types/src/web/primitives/dock.d.ts +42 -0
- package/dist/types/src/web/primitives/dropdown-menu.d.ts +14 -0
- package/dist/types/src/web/primitives/empty-state.d.ts +15 -0
- package/dist/types/src/web/primitives/glass-card.d.ts +4 -0
- package/dist/types/src/web/primitives/hover-card.d.ts +7 -0
- package/dist/types/src/web/primitives/icon-disc.d.ts +12 -0
- package/dist/types/src/web/primitives/index.d.ts +48 -0
- package/dist/types/src/web/primitives/input.d.ts +5 -0
- package/dist/types/src/web/primitives/kbd.d.ts +4 -0
- package/dist/types/src/web/primitives/label.d.ts +3 -0
- package/dist/types/src/web/primitives/menu-surface.d.ts +17 -0
- package/dist/types/src/web/primitives/meter.d.ts +21 -0
- package/dist/types/src/web/primitives/popover.d.ts +3 -0
- package/dist/types/src/web/primitives/progress-ring.d.ts +5 -0
- package/dist/types/src/web/primitives/progress.d.ts +8 -0
- package/dist/types/src/web/primitives/scroll-area.d.ts +49 -0
- package/dist/types/src/web/primitives/segmented-control.d.ts +5 -0
- package/dist/types/src/web/primitives/select.d.ts +47 -0
- package/dist/types/src/web/primitives/separator.d.ts +3 -0
- package/dist/types/src/web/primitives/sheet-chrome.d.ts +4 -0
- package/dist/types/src/web/primitives/sheet.d.ts +20 -0
- package/dist/types/src/web/primitives/skeleton.d.ts +3 -0
- package/dist/types/src/web/primitives/spinner.d.ts +3 -0
- package/dist/types/src/web/primitives/switch.d.ts +4 -0
- package/dist/types/src/web/primitives/table.d.ts +11 -0
- package/dist/types/src/web/primitives/tabs.d.ts +13 -0
- package/dist/types/src/web/primitives/textarea.d.ts +3 -0
- package/dist/types/src/web/primitives/toast.d.ts +163 -0
- package/dist/types/src/web/primitives/tooltip.d.ts +3 -0
- package/dist/types/src/web/primitives/truncate.d.ts +7 -0
- package/dist/types/src/web/primitives/typeahead-portal.d.ts +12 -0
- package/dist/types/src/web/share.d.ts +6 -0
- package/dist/types/src/web/table.d.ts +20 -0
- package/dist/types/src/web/theme-css.d.ts +69 -0
- package/dist/types/src/web/urls.d.ts +21 -0
- package/dist/web/index.js +2303 -0
- package/dist/web/index.js.map +26 -0
- package/dist/web/primitives/index.js +1450 -0
- package/dist/web/primitives/index.js.map +57 -0
- package/docs/components.generated.json +6238 -0
- package/docs/round-1-contract.md +679 -0
- package/docs/round-2-contract.md +476 -0
- package/docs/round-3-contract.md +174 -0
- package/docs/themes.generated.json +3503 -0
- package/fixtures/animations.json +2890 -0
- package/fixtures/bench-list.json +58 -0
- package/fixtures/bind-time.json +30664 -0
- package/fixtures/catalog-basic-map.json +1692 -0
- package/fixtures/catalog-components.json +9323 -0
- package/fixtures/catalog-extension.json +620 -0
- package/fixtures/catalog-macros.json +36370 -0
- package/fixtures/code-tokens.json +1993 -0
- package/fixtures/conformance-baseline.json +55544 -0
- package/fixtures/conformance-cases.json +49 -0
- package/fixtures/conformance-known.json +2123 -0
- package/fixtures/control-geometry.json +4200 -0
- package/fixtures/dashboard.data.json +74 -0
- package/fixtures/dashboard.json +752 -0
- package/fixtures/demo-surface.json +47 -0
- package/fixtures/format.json +870 -0
- package/fixtures/host-policy.json +1067 -0
- package/fixtures/host-router.json +1152 -0
- package/fixtures/host-transport.json +590 -0
- package/fixtures/interactions-round1.json +773 -0
- package/fixtures/kitchen-sink.expanded.json +10844 -0
- package/fixtures/kitchen-sink.json +3049 -0
- package/fixtures/layout-geometry-round1.json +7278 -0
- package/fixtures/layout-geometry.json +2072 -0
- package/fixtures/overlay-geometry.json +272 -0
- package/fixtures/primitive-specimens.json +58 -0
- package/fixtures/prompt-budget.json +33 -0
- package/fixtures/resizable.json +714 -0
- package/fixtures/round4-contract.json +2985 -0
- package/fixtures/specimens.json +9832 -0
- package/fixtures/style-conditions.json +336 -0
- package/fixtures/template-items.json +2215 -0
- package/fixtures/text-direction.json +309 -0
- package/fixtures/text-shimmer.json +96 -0
- package/fixtures/theme-extends.json +573 -0
- package/fixtures/theme-invalid.json +1296 -0
- package/fixtures/theme-recipes.json +58600 -0
- package/fixtures/theme-resolved.json +23949 -0
- package/fixtures/toast-stack.json +215 -0
- package/fixtures/tree-guides.json +224 -0
- package/fixtures/virtual-list.json +733 -0
- package/generated/ExponentialUICatalog.generated.kt +67 -0
- package/generated/ExponentialUICatalog.generated.swift +67 -0
- package/generated/ExponentialUIThemes.generated.kt +23 -0
- package/generated/ExponentialUIThemes.generated.swift +23 -0
- package/generated/catalog.descriptions.generated.rs +6 -0
- package/generated/catalog.generated.rs +80 -0
- package/generated/catalog.themes.generated.rs +34 -0
- package/generated/core.descriptions.json +2505 -0
- package/generated/core.runtime.json +3609 -0
- package/package.json +87 -4
- package/themes/exponential.recipes.json +49 -0
- package/themes/exponential.theme.json +755 -0
- package/themes/neutral.theme.json +5676 -0
- package/themes/orange.theme.json +43 -0
- package/themes/playful.theme.json +219 -0
- package/vendor/a2ui/LICENSE.txt +203 -0
- package/vendor/a2ui/README.md +27 -0
- package/vendor/a2ui/v0_9/basic_catalog.json +1387 -0
- package/vendor/a2ui/v0_9/basic_catalog_rules.txt +5 -0
- package/vendor/a2ui/v0_9/client_capabilities.json +97 -0
- package/vendor/a2ui/v0_9/client_to_server.json +104 -0
- package/vendor/a2ui/v0_9/common_types.json +315 -0
- package/vendor/a2ui/v0_9/server_to_client.json +132 -0
- package/vendor/json-render/LICENSE.txt +201 -0
- package/vendor/json-render/README.md +13 -0
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
// FEED-128 (from VAPP-91's React runner): the SHARED browser conformance
|
|
2
|
+
// runner of every WEB renderer. Every suite of `manifest.json`: the pure
|
|
3
|
+
// ones through the TS reference (the core + the shared web layer
|
|
4
|
+
// `@exponential-at/ui/web`) plus the renderer's own logic (`options.logic`),
|
|
5
|
+
// the painted ones in headless Chromium through the renderer's HARNESS
|
|
6
|
+
// (browser-page.ts says the page protocol). A plain async function (the core
|
|
7
|
+
// carries no vitest): it returns the report and the blocks that threw; the
|
|
8
|
+
// renderer's thin test writes the report and asserts `checkReport`.
|
|
9
|
+
|
|
10
|
+
import { readFileSync } from "node:fs"
|
|
11
|
+
import { join } from "node:path"
|
|
12
|
+
import type { Browser } from "playwright"
|
|
13
|
+
import {
|
|
14
|
+
A2UI_BASIC_CATALOG_ID,
|
|
15
|
+
BUILTIN_THEMES,
|
|
16
|
+
BUILTIN_THEME_IDS,
|
|
17
|
+
CORE_CATALOG_ID,
|
|
18
|
+
HostRouter,
|
|
19
|
+
JsonlDecoder,
|
|
20
|
+
MODES,
|
|
21
|
+
SseDecoder,
|
|
22
|
+
UNKNOWN_COMPONENT,
|
|
23
|
+
builtinTheme,
|
|
24
|
+
checkGeometry,
|
|
25
|
+
clientCapabilities,
|
|
26
|
+
combineDecisions,
|
|
27
|
+
decideFunction,
|
|
28
|
+
decideUrl,
|
|
29
|
+
defineExtension,
|
|
30
|
+
loadTheme,
|
|
31
|
+
THEME_SCHEMA_ID,
|
|
32
|
+
mcpActionCall,
|
|
33
|
+
mediaRequest,
|
|
34
|
+
messagesFromMcpResult,
|
|
35
|
+
parseSource,
|
|
36
|
+
placeOverlay,
|
|
37
|
+
preorder,
|
|
38
|
+
reduceNested,
|
|
39
|
+
reduceSurface,
|
|
40
|
+
resolveRecipe,
|
|
41
|
+
supportedCatalogIds,
|
|
42
|
+
tryLoadTheme,
|
|
43
|
+
validatePackage,
|
|
44
|
+
DEFAULT_STRINGS,
|
|
45
|
+
animationFrame,
|
|
46
|
+
displayString,
|
|
47
|
+
intlFormatter,
|
|
48
|
+
scrollOffsetForIndex,
|
|
49
|
+
tableRowKeys,
|
|
50
|
+
tokenizeCode,
|
|
51
|
+
keyframesCss,
|
|
52
|
+
resolveStyleValues,
|
|
53
|
+
styleToCss,
|
|
54
|
+
} from "../src/index"
|
|
55
|
+
import type { ChildTemplate, Decoded, ExtensionDef, FlatComponent, ModeName, NestedNode, UiNode, AppPackage } from "../src/index"
|
|
56
|
+
import { conformanceManifest } from "../src/conformance"
|
|
57
|
+
import type { ConformanceReport } from "../src/conformance"
|
|
58
|
+
import { BASE_CSS, CLIENT_FUNCTIONS, LITERAL_ROWS_ROOT, absolutePath, bindTree, isBinding, memoFormatter, resolveNodeProps, resolveValue, setPointer, type DataModel, type ResolveContext } from "../src/web/index"
|
|
59
|
+
import { launchChromium, openPage, pageUrl, type Query, type WebHarness } from "./browser-page"
|
|
60
|
+
|
|
61
|
+
/** The renderer-side functions the pure suites run (everything else is the
|
|
62
|
+
* core or the shared web layer). `Ctx` = the renderer's surface context;
|
|
63
|
+
* the runner hands it a STUB carrying only what these functions read:
|
|
64
|
+
* `templateItems` gets `{ data, templateNode }`, `runNodeAction` gets
|
|
65
|
+
* `{ surfaceId, data, functions, openUrl, locale, strings, formatter,
|
|
66
|
+
* setData(pointer, value), host: { onAction, onFunctionCall } }`. */
|
|
67
|
+
export interface BrowserSuiteLogic<Ctx = never> {
|
|
68
|
+
/** A templated node's items in `scope` (`null` = no template / no list). */
|
|
69
|
+
templateItems(ctx: Ctx, node: UiNode, scope: string): { items: { key: string; path: string; index: number }[] } | null | undefined
|
|
70
|
+
/** A template item's instance suffix (`${instance}.<segment of key>`). */
|
|
71
|
+
itemInstance(instance: string, key: string): string
|
|
72
|
+
/** Runs the node's `event` action (`set` writes through `ctx.setData`, any
|
|
73
|
+
* other function reaches `ctx.host.onFunctionCall` / `onAction`). */
|
|
74
|
+
runNodeAction(ctx: Ctx, node: UiNode, domId: string, scope: string, event: string): unknown
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface BrowserSuitesOptions<Ctx = never> {
|
|
78
|
+
/** The report's identity: package name, `web`, package version. */
|
|
79
|
+
renderer: { name: string; platform: string; version: string }
|
|
80
|
+
/** Serves the renderer's harness bundle on a loopback port. */
|
|
81
|
+
harness: WebHarness
|
|
82
|
+
logic: BrowserSuiteLogic<Ctx>
|
|
83
|
+
/** Extra Chromium flags (default none). */
|
|
84
|
+
chromiumArgs?: string[]
|
|
85
|
+
/** Progress lines (default silent). */
|
|
86
|
+
log?: (line: string) => void
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface BrowserSuitesResult {
|
|
90
|
+
report: ConformanceReport
|
|
91
|
+
/** `<block>: <error>` per block that threw (its cases so far stay recorded). */
|
|
92
|
+
failures: string[]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The harness probes the painted suites read (typed here, not as a global
|
|
96
|
+
* `Window` augmentation: each renderer's harness declares its own). */
|
|
97
|
+
interface XuiProbes {
|
|
98
|
+
__xuiFrames?: (rootId?: string) => { id: string; x: number; y: number; w: number; h: number }[]
|
|
99
|
+
__xuiOverlay?: () => { x: number; y: number; w: number; h: number; side: string } | null
|
|
100
|
+
__xuiControls?: () => Record<string, Record<string, number> | null>
|
|
101
|
+
__xuiHandle?: () => { scrollToIndex(id: string, index: number, align: `start` | `center` | `end` | `nearest`): void } | null
|
|
102
|
+
__xuiMount?: (tree: NestedNode, options?: Record<string, unknown>) => Promise<void>
|
|
103
|
+
__xuiVList?: (c: never) => Promise<{ scroll(at: number): Promise<unknown>; scrollToIndex(index: number, align: `start` | `center` | `end` | `nearest`): Promise<number> }>
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const fixture = (name: string) => JSON.parse(readFileSync(join(import.meta.dirname, `..`, `fixtures`, name), `utf8`))
|
|
107
|
+
const canon = (v: unknown) => JSON.stringify(v)
|
|
108
|
+
|
|
109
|
+
function walk(node: UiNode, visit: (n: UiNode) => void): void {
|
|
110
|
+
visit(node)
|
|
111
|
+
for (const slot of Object.values(node.slots ?? {})) walk(slot, visit)
|
|
112
|
+
node.children.forEach((c) => walk(c, visit))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function runBrowserSuites<Ctx = never>(options: BrowserSuitesOptions<Ctx>): Promise<BrowserSuitesResult> {
|
|
116
|
+
const { logic } = options
|
|
117
|
+
type Suite = { cases: number; passed: number; failed: string[] }
|
|
118
|
+
const suites: Record<string, Suite> = {}
|
|
119
|
+
const failures: string[] = []
|
|
120
|
+
function record(suite: string, name: string, ok: boolean | string | (() => boolean | string), detail = ``) {
|
|
121
|
+
const s = (suites[suite] ??= { cases: 0, passed: 0, failed: [] })
|
|
122
|
+
s.cases += 1
|
|
123
|
+
let verdict: boolean | string
|
|
124
|
+
try {
|
|
125
|
+
verdict = typeof ok === `function` ? ok() : ok
|
|
126
|
+
} catch (e) {
|
|
127
|
+
verdict = e instanceof Error ? e.message : String(e)
|
|
128
|
+
}
|
|
129
|
+
if (verdict === true) s.passed += 1
|
|
130
|
+
else s.failed.push(`${name}${typeof verdict === `string` ? `: ${verdict}` : detail ? `: ${detail}` : ``}`)
|
|
131
|
+
}
|
|
132
|
+
const block = async (name: string, fn: () => unknown) => {
|
|
133
|
+
const started = Date.now()
|
|
134
|
+
try {
|
|
135
|
+
await fn()
|
|
136
|
+
} catch (e) {
|
|
137
|
+
failures.push(`${name}: ${e instanceof Error ? (e.stack ?? e.message) : String(e)}`)
|
|
138
|
+
}
|
|
139
|
+
options.log?.(`${name}: ${((Date.now() - started) / 1000).toFixed(1)} s`)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const served = await options.harness.start()
|
|
143
|
+
let browser: Browser | undefined
|
|
144
|
+
try {
|
|
145
|
+
browser = await launchChromium(options.chromiumArgs ?? [])
|
|
146
|
+
const b = browser
|
|
147
|
+
const url = (query: Query) => pageUrl(served.baseUrl, query)
|
|
148
|
+
const open = (query: Query, viewport?: { width: number; height: number }) => openPage(b, served.baseUrl, query, viewport)
|
|
149
|
+
await runAll(b, url, open)
|
|
150
|
+
} finally {
|
|
151
|
+
await browser?.close()
|
|
152
|
+
await served.stop()
|
|
153
|
+
}
|
|
154
|
+
const report: ConformanceReport = { renderer: options.renderer.name, platform: options.renderer.platform, version: options.renderer.version, conformanceVersion: conformanceManifest().version, suites }
|
|
155
|
+
return { report, failures }
|
|
156
|
+
|
|
157
|
+
async function runAll(browser: Browser, url: (q: Query) => string, open: (q: Query, viewport?: { width: number; height: number }) => ReturnType<typeof openPage>) {
|
|
158
|
+
await block(`catalog: every case reduces cleanly and paints without Unknown`, async () => {
|
|
159
|
+
const cases = fixture(`catalog-components.json`).cases as { name: string; node: NestedNode }[]
|
|
160
|
+
const page = await open({ view: `catalog`, theme: `exponential`, mode: `light` }, { width: 1400, height: 900 })
|
|
161
|
+
// Every case is painted once its last surface (a toast in its layer) is.
|
|
162
|
+
await page.waitForFunction(() => document.querySelector(`[data-xui-surface="Toast-example"] [data-xui-id]`) !== null, undefined, { timeout: 60_000 })
|
|
163
|
+
const painted = (await page.evaluate(() => Array.from(document.querySelectorAll(`.xui-surface`)).map((s) => ({ id: s.getAttribute(`data-xui-surface`), nodes: s.querySelectorAll(`[data-xui-id]`).length, unknown: s.querySelectorAll(`.xui-Unknown-root, [data-xui-component="Unknown"]`).length })))) as { id: string; nodes: number; unknown: number }[]
|
|
164
|
+
await page.context().close()
|
|
165
|
+
const byId = new Map(painted.map((p) => [p.id, p]))
|
|
166
|
+
for (const c of cases)
|
|
167
|
+
record(`catalog`, c.name, () => {
|
|
168
|
+
const { issues } = reduceNested(c.node, { catalogId: CORE_CATALOG_ID })
|
|
169
|
+
if (issues.length) return `issues ${canon(issues)}`
|
|
170
|
+
const p = byId.get(c.name.replace(/[^a-z0-9]/gi, `-`))
|
|
171
|
+
if (!p) return `not painted`
|
|
172
|
+
if (p.unknown) return `Unknown painted`
|
|
173
|
+
// A closed overlay (round 1: `Toast/open=false`) paints nothing at all.
|
|
174
|
+
return p.nodes > 0 || (c.node.props as { open?: unknown } | undefined)?.open === false || `no nodes`
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
await block(`macros, basic-map, extension`, () => {
|
|
179
|
+
for (const c of fixture(`catalog-macros.json`).cases as { name: string; input: NestedNode; expected: UiNode }[])
|
|
180
|
+
record(`macros`, c.name, () => canon(reduceNested(c.input, { catalogId: CORE_CATALOG_ID }).root) === canon(c.expected))
|
|
181
|
+
for (const c of fixture(`catalog-basic-map.json`).cases as { name: string; components: FlatComponent[]; expected: unknown }[])
|
|
182
|
+
record(`basic-map`, c.name, () => canon(reduceSurface(c.components, { catalogId: A2UI_BASIC_CATALOG_ID })) === canon(c.expected))
|
|
183
|
+
const ext = fixture(`catalog-extension.json`)
|
|
184
|
+
const extension = defineExtension(ext.extension as ExtensionDef)
|
|
185
|
+
for (const c of ext.cases as { name: string; catalogId: string; components: FlatComponent[]; expected: unknown }[])
|
|
186
|
+
record(`extension`, c.name, () => canon(reduceSurface(c.components, { catalogId: c.catalogId, extensions: [extension] })) === canon(c.expected))
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
await block(`themes`, () => {
|
|
190
|
+
const resolved = fixture(`theme-resolved.json`).themes as Record<string, unknown>
|
|
191
|
+
for (const [id, expected] of Object.entries(resolved)) record(`theme-resolved`, id, () => canon(builtinTheme(id)) === canon(expected))
|
|
192
|
+
for (const c of fixture(`theme-recipes.json`).cases as { component: string; part: string; props: Record<string, unknown>; visuals: Record<string, Record<string, Record<string, unknown>>> }[])
|
|
193
|
+
record(`theme-recipes`, `${c.component}/${c.part} ${canon(c.props)}`, () => {
|
|
194
|
+
for (const [themeId, byMode] of Object.entries(c.visuals))
|
|
195
|
+
for (const [mode, byState] of Object.entries(byMode))
|
|
196
|
+
for (const [state, style] of Object.entries(byState)) {
|
|
197
|
+
const states = state === `default` ? [] : [state]
|
|
198
|
+
if (canon(resolveRecipe(builtinTheme(themeId), { component: c.component, part: c.part, props: c.props, states }, mode as ModeName)) !== canon(style)) return `${themeId}/${mode}/${state}`
|
|
199
|
+
}
|
|
200
|
+
return true
|
|
201
|
+
})
|
|
202
|
+
for (const c of fixture(`theme-extends.json`).cases as { name: string; theme: never; expected: { chain: string[]; probes: { component: string; part: string; props: Record<string, unknown>; states: string[]; mode: ModeName; style: unknown }[] } }[])
|
|
203
|
+
record(`theme-extends`, c.name, () => {
|
|
204
|
+
const theme = loadTheme(c.theme, { themes: BUILTIN_THEMES })
|
|
205
|
+
if (canon(theme.chain) !== canon(c.expected.chain)) return `chain`
|
|
206
|
+
return c.expected.probes.every((p) => canon(resolveRecipe(theme, { component: p.component, part: p.part, props: p.props, states: p.states }, p.mode)) === canon(p.style))
|
|
207
|
+
})
|
|
208
|
+
for (const c of fixture(`theme-invalid.json`).cases as { name: string; theme: unknown; issues: unknown }[])
|
|
209
|
+
record(`theme-invalid`, c.name, () => {
|
|
210
|
+
const { theme, issues } = tryLoadTheme(c.theme, { themes: BUILTIN_THEMES })
|
|
211
|
+
return theme === null && canon(issues) === canon(c.issues)
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
await block(`control-geometry: the painted controls keep the theme's box`, async () => {
|
|
216
|
+
const themes = fixture(`control-geometry.json`).themes as Record<string, Record<string, { part: string; cases: Record<string, { geometry: Record<string, number> }> }>>
|
|
217
|
+
for (const [themeId, byComponent] of Object.entries(themes)) {
|
|
218
|
+
const page = await open({ view: `controls`, theme: themeId }, { width: 1400, height: 900 })
|
|
219
|
+
const boxes = (await page.evaluate(() => (window as unknown as XuiProbes).__xuiControls!())) as Record<string, Record<string, number> | null>
|
|
220
|
+
await page.context().close()
|
|
221
|
+
for (const [component, entry] of Object.entries(byComponent))
|
|
222
|
+
for (const [name, c] of Object.entries(entry.cases))
|
|
223
|
+
record(`control-geometry`, `${themeId} ${component} ${name}`, () => {
|
|
224
|
+
const box = boxes[`${component} ${name}`]
|
|
225
|
+
if (!box) return `part ${entry.part} not painted`
|
|
226
|
+
const issues = checkGeometry(c.geometry, box, 0.5)
|
|
227
|
+
return issues.length === 0 || issues.map((i) => `${i.key} ${i.expected}≠${i.actual}`).join(`, `)
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
await block(`layout: frames within 1 px of taffy`, async () => {
|
|
233
|
+
const geometry = fixture(`layout-geometry.json`)
|
|
234
|
+
const tolerance = geometry.measure.tolerancePx as number
|
|
235
|
+
for (const [name, c] of Object.entries(geometry.cases as Record<string, { frames: { id: string; x: number; y: number; w: number; h: number }[] }>)) {
|
|
236
|
+
const page = await open({ view: `geometry`, case: name })
|
|
237
|
+
const frames = (await page.evaluate(() => (window as unknown as XuiProbes).__xuiFrames!())) as { id: string; x: number; y: number; w: number; h: number }[]
|
|
238
|
+
await page.context().close()
|
|
239
|
+
const byId = new Map(frames.map((f) => [f.id, f]))
|
|
240
|
+
record(`layout`, name, () => {
|
|
241
|
+
if (frames.length !== c.frames.length) return `${frames.length} frames, want ${c.frames.length}`
|
|
242
|
+
for (const t of c.frames) {
|
|
243
|
+
const b = byId.get(t.id)
|
|
244
|
+
if (!b) return `${t.id} missing`
|
|
245
|
+
for (const axis of [`x`, `y`, `w`, `h`] as const) if (Math.abs(b[axis] - t[axis]) > tolerance) return `${t.id}.${axis}`
|
|
246
|
+
}
|
|
247
|
+
return true
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
await block(`overlay: placement, side and flip`, async () => {
|
|
253
|
+
const overlays = fixture(`overlay-geometry.json`)
|
|
254
|
+
const cases = overlays.cases as { name: string; viewport: { width: number; height: number }; anchor: { x: number; y: number; width: number; height: number }; side: `top`; expected: { side: string } }[]
|
|
255
|
+
for (const [i, c] of cases.entries()) {
|
|
256
|
+
const page = await open({ view: `overlay`, case: i }, { width: c.viewport.width, height: c.viewport.height })
|
|
257
|
+
await page.waitForSelector(`[data-xui-overlay="Popover"]`)
|
|
258
|
+
await page.waitForTimeout(150)
|
|
259
|
+
const got = (await page.evaluate(() => (window as unknown as XuiProbes).__xuiOverlay!())) as { x: number; y: number; w: number; h: number; side: string } | null
|
|
260
|
+
await page.context().close()
|
|
261
|
+
record(`overlay`, c.name, () => {
|
|
262
|
+
if (!got) return `not painted`
|
|
263
|
+
const want = placeOverlay(c.anchor, { width: got.w, height: got.h }, c.viewport, { side: c.side })
|
|
264
|
+
return got.side === c.expected.side && want.side === c.expected.side && Math.abs(got.x - want.x) <= overlays.tolerancePx && Math.abs(got.y - want.y) <= overlays.tolerancePx
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
await block(`replay: the kitchen sink in every built-in theme and mode`, async () => {
|
|
270
|
+
const expanded = fixture(`kitchen-sink.expanded.json`)
|
|
271
|
+
const sink = fixture(`kitchen-sink.json`) as NestedNode
|
|
272
|
+
const order = preorder(expanded.root as UiNode)
|
|
273
|
+
for (const themeId of BUILTIN_THEME_IDS)
|
|
274
|
+
for (const mode of MODES) {
|
|
275
|
+
const page = await open({ view: `kitchen-sink`, theme: themeId, mode })
|
|
276
|
+
const painted = (await page.evaluate(() => Array.from(document.querySelectorAll(`[data-xui-surface="ks"] [data-xui-id]`)).map((e) => (e as HTMLElement).dataset.xuiId!))) as string[]
|
|
277
|
+
await page.context().close()
|
|
278
|
+
record(`replay`, `${themeId}/${mode}`, () => {
|
|
279
|
+
const reduced = reduceNested(sink, { catalogId: CORE_CATALOG_ID })
|
|
280
|
+
if (canon(reduced.root) !== canon(expanded.root) || canon(reduced.issues) !== canon(expanded.issues)) return `reduced tree differs`
|
|
281
|
+
let unknown = false
|
|
282
|
+
walk(reduced.root, (n) => (unknown ||= n.component === UNKNOWN_COMPONENT))
|
|
283
|
+
if (unknown) return `Unknown`
|
|
284
|
+
// DOM order = pre-order (the a11y order) for every painted node.
|
|
285
|
+
const idx = painted.filter((id) => order.includes(id)).map((id) => order.indexOf(id))
|
|
286
|
+
if (idx.length < 50) return `only ${idx.length} nodes painted`
|
|
287
|
+
return idx.every((v, i) => i === 0 || v > idx[i - 1]!) || `DOM order is not the pre-order`
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
await block(`host-transport, host-policy, host-router`, () => {
|
|
293
|
+
const feed = (dec: { push(c: string): Decoded; end(): Decoded }, chunks: string[]) => {
|
|
294
|
+
const out: Decoded = { messages: [], issues: [] }
|
|
295
|
+
for (const d of [...chunks.map((c) => dec.push(c)), dec.end()]) {
|
|
296
|
+
out.messages.push(...d.messages)
|
|
297
|
+
out.issues.push(...d.issues)
|
|
298
|
+
}
|
|
299
|
+
return out
|
|
300
|
+
}
|
|
301
|
+
const t = fixture(`host-transport.json`)
|
|
302
|
+
for (const c of t.jsonl) record(`host-transport`, `jsonl: ${c.name}`, () => canon(feed(new JsonlDecoder(), c.chunks)) === canon(c.expected))
|
|
303
|
+
for (const c of t.sse) record(`host-transport`, `sse: ${c.name}`, () => canon(feed(new SseDecoder(), c.chunks)) === canon(c.expected))
|
|
304
|
+
for (const c of t.mcp) record(`host-transport`, `mcp: ${c.name}`, () => canon(messagesFromMcpResult(c.result)) === canon(c.expected))
|
|
305
|
+
for (const c of t.mcpAction) record(`host-transport`, `mcpAction: ${c.name}`, () => canon(mcpActionCall(c.message, c.tool)) === canon(c.expected))
|
|
306
|
+
const p = fixture(`host-policy.json`)
|
|
307
|
+
for (const c of p.functions) record(`host-policy`, `function: ${c.name}`, () => decideFunction(c.policy, c.fn, c.registered) === c.expected)
|
|
308
|
+
for (const c of p.combine) record(`host-policy`, `combine: ${c.a}+${c.b}`, () => combineDecisions(c.a, c.b) === c.expected)
|
|
309
|
+
for (const c of p.urls) record(`host-policy`, `url: ${c.name}`, () => canon(decideUrl(c.policy, c.url)) === canon(c.expected))
|
|
310
|
+
for (const c of p.media) record(`host-policy`, `media: ${c.name}`, () => canon(mediaRequest(c.url, c.options)) === canon(c.expected))
|
|
311
|
+
for (const c of p.sources) record(`host-policy`, `source: ${c.uri}`, () => canon(parseSource(c.uri)) === canon(c.expected))
|
|
312
|
+
for (const c of p.negotiation)
|
|
313
|
+
record(`host-policy`, `negotiation: ${c.extensionIds.length}`, () => canon(supportedCatalogIds(c.extensionIds)) === canon(c.expected.supportedCatalogIds) && canon(clientCapabilities(c.extensionIds)) === canon(c.expected.clientCapabilities))
|
|
314
|
+
const r = fixture(`host-router.json`)
|
|
315
|
+
const packages = r.packages as Record<string, AppPackage>
|
|
316
|
+
for (const v of r.validation) record(`host-router`, `validation: ${v.package}`, () => canon(validatePackage(packages[v.package])) === canon(v.expected))
|
|
317
|
+
for (const flow of r.flows as { name: string; extensionIds?: string[]; packages?: string[]; installIssues?: Record<string, unknown>; steps: { message: unknown; expected: unknown }[] }[])
|
|
318
|
+
record(`host-router`, `flow: ${flow.name}`, () => {
|
|
319
|
+
const router = new HostRouter({ extensionIds: flow.extensionIds ?? [] })
|
|
320
|
+
for (const id of flow.packages ?? []) if (canon(router.installPackage(packages[id]!)) !== canon(flow.installIssues![id])) return `install ${id}`
|
|
321
|
+
for (const [i, s] of flow.steps.entries()) if (canon(router.route(s.message)) !== canon(s.expected)) return `step ${i}`
|
|
322
|
+
return true
|
|
323
|
+
})
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
// Round 2 (manifest v2) through THIS renderer: the bind pass is the
|
|
327
|
+
// shared web layer's (`bindTree`), the action runner the renderer's
|
|
328
|
+
// (`logic.runNodeAction`), format calls run the bind table on the surface
|
|
329
|
+
// Formatter, template instances are the renderer's `templateItems` +
|
|
330
|
+
// `itemInstance`; style conditions, text direction, Resizable and the
|
|
331
|
+
// windowed list are PAINTED in Chromium and read back. Where the renderer
|
|
332
|
+
// calls the core directly (tokenizeCode, the
|
|
333
|
+
// reducer, tableRowKeys, an arbitrary scrollTo inset, the animation
|
|
334
|
+
// frames) the core replay stands in, said per line.
|
|
335
|
+
await block(`bind, code-tokens (the renderer's bind pass and action runner)`, () => {
|
|
336
|
+
const strings = DEFAULT_STRINGS as Readonly<Record<string, string>>
|
|
337
|
+
const formatter = memoFormatter(intlFormatter(`en-US`, `UTC`))
|
|
338
|
+
const rctx = (data: unknown, scope = ``): ResolveContext => ({ data, scope, strings, locale: `en-US`, formatter, functions: CLIENT_FUNCTIONS })
|
|
339
|
+
const find = (n: UiNode, id: string): UiNode | undefined => {
|
|
340
|
+
let hit: UiNode | undefined
|
|
341
|
+
walk(n, (x) => (hit ??= x.id === id ? x : undefined))
|
|
342
|
+
return hit
|
|
343
|
+
}
|
|
344
|
+
/** A press through `runNodeAction` on a stub surface: `set` writes a copy
|
|
345
|
+
* of the data, any other function is the host's `call`. */
|
|
346
|
+
const press = (node: UiNode, data: unknown) => {
|
|
347
|
+
let out = data as DataModel
|
|
348
|
+
let call: { call: string; args: Record<string, unknown> } | undefined
|
|
349
|
+
const fn = node.on?.press as { functionCall?: { call: string } } | undefined
|
|
350
|
+
const name = fn?.functionCall?.call
|
|
351
|
+
const functions = Object.fromEntries(Object.entries(CLIENT_FUNCTIONS).map(([k, f]) => [k, (args: Record<string, unknown>, c: ResolveContext) => {
|
|
352
|
+
if (k === name) call = { call: k, args }
|
|
353
|
+
return f(args, { ...c, openUrl: () => {} })
|
|
354
|
+
}]))
|
|
355
|
+
const ctx = { surfaceId: `s`, data, functions, openUrl: () => {}, locale: `en-US`, strings, formatter, setData: (p: string, v: unknown) => (out = setPointer(out, p, v)), host: { onAction: () => undefined, onFunctionCall: (e: { name: string; args: Record<string, unknown> }) => void (call = { call: e.name, args: e.args }) } } as unknown as Ctx
|
|
356
|
+
logic.runNodeAction(ctx, node, node.id, ``, `press`)
|
|
357
|
+
return call ? { data: out, call } : { data: out }
|
|
358
|
+
}
|
|
359
|
+
type BindCase = { name: string; input?: NestedNode; expanded: UiNode; issues?: unknown[]; datasets: { data: unknown; bound: UiNode | null; presses: { id: string; outcome: unknown }[]; rowSlots?: { id: string; rows: { index: number; slots: Record<string, UiNode | null> }[] }[] }[] }
|
|
360
|
+
const bind = fixture(`bind-time.json`) as { cases: BindCase[]; extra: BindCase[] }
|
|
361
|
+
for (const c of [...bind.cases, ...bind.extra])
|
|
362
|
+
record(`bind`, c.name, () => {
|
|
363
|
+
if (c.input && c.issues) {
|
|
364
|
+
const r = reduceNested(c.input, { catalogId: CORE_CATALOG_ID })
|
|
365
|
+
if (canon(r.root) !== canon(c.expanded) || canon(r.issues) !== canon(c.issues)) return `expand`
|
|
366
|
+
}
|
|
367
|
+
for (const d of c.datasets) {
|
|
368
|
+
if (canon(bindTree(c.expanded, rctx(d.data))) !== canon(d.bound)) return `bound`
|
|
369
|
+
for (const p of d.presses) if (canon(press(find(c.expanded, p.id)!, d.data)) !== canon(p.outcome)) return `press ${p.id}`
|
|
370
|
+
// A slot cell = the Table's RowScope: bound rows scope the row
|
|
371
|
+
// pointer, literal rows mount the row at a synthetic pointer.
|
|
372
|
+
for (const t of d.rowSlots ?? []) {
|
|
373
|
+
const table = find(c.expanded, t.id)!
|
|
374
|
+
const rowsProp = table.props.rows
|
|
375
|
+
const rows = resolveNodeProps(`Table`, { rows: rowsProp }, rctx(d.data)).rows as Record<string, unknown>[]
|
|
376
|
+
for (const r of t.rows)
|
|
377
|
+
for (const [name, slot] of Object.entries(r.slots)) {
|
|
378
|
+
const literal = `${LITERAL_ROWS_ROOT}/${t.id}/${r.index}`
|
|
379
|
+
const got = isBinding(rowsProp) ? bindTree(table.slots![name], rctx(d.data, `${absolutePath(rowsProp.path)}/${r.index}`)) : bindTree(table.slots![name], rctx(setPointer(d.data as DataModel, literal, rows[r.index]), literal))
|
|
380
|
+
if (canon(got) !== canon(slot)) return `row ${r.index}`
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return true
|
|
385
|
+
})
|
|
386
|
+
// The CodeBlock painter calls the core tokenizer.
|
|
387
|
+
for (const c of fixture(`code-tokens.json`).cases as { name: string; language: string; code: string; expected: unknown }[]) record(`code-tokens`, c.name, () => canon(tokenizeCode(c.code, c.language)) === canon(c.expected))
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
await block(`style-conditions: the compiled CSS in Chromium (hover: none = a touch context)`, async () => {
|
|
391
|
+
type Ctx = { width: number; height?: number; hover?: boolean; reducedMotion?: boolean; states?: string[]; breakpoints?: Record<string, number> }
|
|
392
|
+
const conditions = fixture(`style-conditions.json`) as { cases: { name: string; style: Record<string, unknown>; contexts: Ctx[]; expected: Record<string, unknown>[] }[] }
|
|
393
|
+
for (const [ci, c] of conditions.cases.entries()) {
|
|
394
|
+
let verdict: true | string = true
|
|
395
|
+
for (const [xi, ctx] of c.contexts.entries()) {
|
|
396
|
+
// A touch context (hasTouch) is Chromium's `(hover: none)`.
|
|
397
|
+
const context = await browser.newContext({ viewport: { width: Math.max(ctx.width + 40, 400), height: Math.max((ctx.height ?? 120) + 40, 400) }, reducedMotion: ctx.reducedMotion ? `reduce` : `no-preference`, hasTouch: ctx.hover === false })
|
|
398
|
+
const page = await context.newPage()
|
|
399
|
+
await page.goto(url({ view: `conditions`, case: ci, ctx: xi }))
|
|
400
|
+
await page.waitForSelector(`[data-xui-id="n"]`, { state: `attached` })
|
|
401
|
+
await page.waitForTimeout(100)
|
|
402
|
+
const theme = ctx.breakpoints ? loadTheme({ $schema: THEME_SCHEMA_ID, id: `bp`, name: `Breakpoints`, extends: `neutral`, tokens: { breakpoint: ctx.breakpoints } } as never, { themes: BUILTIN_THEMES }) : builtinTheme(`neutral`)
|
|
403
|
+
const want = styleToCss(resolveStyleValues(theme, c.expected[xi], `light`), theme.fonts)
|
|
404
|
+
if (ctx.reducedMotion) delete want.transition // reduced motion zeroes every duration (contract §2)
|
|
405
|
+
const got = await page.evaluate((css) => {
|
|
406
|
+
const node = document.querySelector(`[data-xui-id="n"]`) as HTMLElement
|
|
407
|
+
const probe = document.createElement(`div`)
|
|
408
|
+
probe.className = `xui-el`
|
|
409
|
+
node.parentElement!.appendChild(probe)
|
|
410
|
+
for (const [k, v] of Object.entries(css as Record<string, string>)) probe.style.setProperty(k, v)
|
|
411
|
+
const a = getComputedStyle(node)
|
|
412
|
+
const b = getComputedStyle(probe)
|
|
413
|
+
const bad = Object.keys(css).map((k) => (k === `transition` ? `transition-duration` : k)).filter((k) => a.getPropertyValue(k) !== b.getPropertyValue(k))
|
|
414
|
+
probe.remove()
|
|
415
|
+
return bad
|
|
416
|
+
}, want)
|
|
417
|
+
await context.close()
|
|
418
|
+
if (got.length) {
|
|
419
|
+
verdict = `context ${xi}: ${got.join(`, `)}`
|
|
420
|
+
break
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
record(`style-conditions`, c.name, verdict)
|
|
424
|
+
}
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
await block(`format, template-items (the renderer's bind table + template keying)`, () => {
|
|
428
|
+
const formatter = memoFormatter(intlFormatter(`en-US`, `UTC`))
|
|
429
|
+
const f = fixture(`format.json`) as { calls: { name: string; call: unknown; expected: unknown }[]; zoned: { name: string; timeZone: string; call: unknown; expected: string }[]; display: { name: string; value: unknown; expected: string }[] }
|
|
430
|
+
for (const c of f.calls) record(`format`, c.name, () => canon(resolveValue(c.call, { data: {}, locale: `en-US`, formatter })) === canon(c.expected))
|
|
431
|
+
// `zoned`: the surface Formatter in the case's IANA zone (a web renderer's is Intl,
|
|
432
|
+
// a platform Formatter: U+202F / U+00A0 compare as a space).
|
|
433
|
+
const spaces = (v: unknown) => String(v).replace(/[ ]/g, ` `)
|
|
434
|
+
for (const c of f.zoned) record(`format`, `zoned: ${c.name}`, () => spaces(resolveValue(c.call, { data: {}, locale: `en-US`, formatter: memoFormatter(intlFormatter(`en-US`, c.timeZone)) })) === spaces(c.expected))
|
|
435
|
+
for (const d of f.display) record(`format`, `display: ${d.name}`, () => displayString(d.value) === d.expected)
|
|
436
|
+
const items = fixture(`template-items.json`)
|
|
437
|
+
const keyed = (data: unknown, template: ChildTemplate, scope: string) => logic.templateItems({ data, templateNode: () => ({}) } as unknown as Ctx, { template } as unknown as UiNode, scope)?.items ?? []
|
|
438
|
+
for (const c of items.keys) record(`template-items`, `keys: ${c.name}`, () => canon(keyed({ items: c.items }, { component: `x`, path: `/items`, ...(c.key === undefined ? {} : { key: c.key }) } as ChildTemplate, ``).map((i) => i.key)) === canon(c.expected))
|
|
439
|
+
// The Table painter keys its rows with the core's `tableRowKeys`.
|
|
440
|
+
for (const c of items.rowKeys) record(`template-items`, `rowKeys: ${c.name}`, () => canon(tableRowKeys(c.rows, c.rowKey)) === canon(c.expected))
|
|
441
|
+
for (const c of items.instances as { name: string; data: unknown; template: ChildTemplate; inner?: ChildTemplate; scope: string; instance: string; expected: unknown[] }[])
|
|
442
|
+
record(`template-items`, `instances: ${c.name}`, () => {
|
|
443
|
+
const outer = keyed(c.data, c.template, c.scope).map((i) => ({ key: i.key, path: i.path, index: i.index, instance: logic.itemInstance(c.instance, i.key) }))
|
|
444
|
+
if (!c.inner) return canon(outer) === canon(c.expected)
|
|
445
|
+
const nested = outer.flatMap((o) => keyed(c.data, c.inner!, o.path).map((i) => {
|
|
446
|
+
const instance = logic.itemInstance(o.instance, i.key)
|
|
447
|
+
return { outer: o.key, key: i.key, path: i.path, index: i.index, instance, ids: { issue: `issue${instance}`, title: `issue.title${instance}` } }
|
|
448
|
+
}))
|
|
449
|
+
return canon(nested) === canon(c.expected)
|
|
450
|
+
})
|
|
451
|
+
// The renderer paints the core reducer's output.
|
|
452
|
+
for (const c of items.reduce as { name: string; catalogId: string; nested?: NestedNode; components?: FlatComponent[]; expected: unknown }[])
|
|
453
|
+
record(`template-items`, `reduce: ${c.name}`, () => canon(c.nested ? reduceNested(c.nested, { catalogId: c.catalogId }) : reduceSurface(c.components!, { catalogId: c.catalogId })) === canon(c.expected))
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
await block(`text-direction, resizable, virtual-list, animations: painted in Chromium`, async () => {
|
|
457
|
+
const context = await browser.newContext({ viewport: { width: 1200, height: 900 }, deviceScaleFactor: 1 })
|
|
458
|
+
const page = await context.newPage()
|
|
459
|
+
await page.goto(url({ view: `mount` }))
|
|
460
|
+
await page.waitForFunction(() => typeof (window as unknown as XuiProbes).__xuiMount === `function` && typeof (window as unknown as XuiProbes).__xuiVList === `function`)
|
|
461
|
+
const mount = (tree: unknown, options: Record<string, unknown> = {}) => page.evaluate(([t, o]) => (window as unknown as XuiProbes).__xuiMount!(t as never, o as never), [tree, options] as const)
|
|
462
|
+
const frames = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(() => requestAnimationFrame(r)))))
|
|
463
|
+
|
|
464
|
+
// text-direction: every node's computed `direction` and its computed
|
|
465
|
+
// `text-align` resolved to the physical side.
|
|
466
|
+
for (const c of fixture(`text-direction.json`).cases as { name: string; tree: NestedNode; surface: `ltr` | `rtl`; expected: Record<string, { direction: string; textAlign: string }> }[]) {
|
|
467
|
+
await mount(c.tree, { dir: c.surface, width: 600 })
|
|
468
|
+
const got = await page.evaluate((ids) => {
|
|
469
|
+
const out: Record<string, { direction: string; textAlign: string }> = {}
|
|
470
|
+
for (const id of ids) {
|
|
471
|
+
const el = document.querySelector<HTMLElement>(`[data-xui-id="${id}"]`)
|
|
472
|
+
if (!el) continue
|
|
473
|
+
const cs = getComputedStyle(el)
|
|
474
|
+
const a = cs.textAlign
|
|
475
|
+
const physical = a === `start` || a === `-webkit-auto` ? (cs.direction === `rtl` ? `right` : `left`) : a === `end` ? (cs.direction === `rtl` ? `left` : `right`) : a === `-webkit-left` ? `left` : a === `-webkit-right` ? `right` : a === `-webkit-center` ? `center` : a
|
|
476
|
+
out[id] = { direction: cs.direction, textAlign: physical }
|
|
477
|
+
}
|
|
478
|
+
return out
|
|
479
|
+
}, Object.keys(c.expected))
|
|
480
|
+
const bad = Object.keys(c.expected).filter((id) => canon(got[id]) !== canon(c.expected[id]))
|
|
481
|
+
record(`text-direction`, c.name, bad.length === 0, bad.map((id) => `${id} ${canon(got[id])}`).join(`; `))
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// resizable: a Resizable painted per case; sizes read off the panels.
|
|
485
|
+
const near = (a: number[], b: number[], tol = 1e-6) => a.length === b.length && a.every((v, i) => Math.abs(v - b[i]!) <= tol)
|
|
486
|
+
const rz = fixture(`resizable.json`)
|
|
487
|
+
const panelsOf = (count: number) => Array.from({ length: count }, (_, i) => ({ id: `p${i}`, component: `Box` }))
|
|
488
|
+
const sizes = (count: number) => page.evaluate((n) => Array.from({ length: n }, (_, i) => Number(document.querySelector<HTMLElement>(`[data-xui-id="rz.panel.${i}"]`)!.dataset.size)), count)
|
|
489
|
+
/** A pointer drag of handle `i` by (dx, dy) px: synthetic pointer events (fractional coordinates). */
|
|
490
|
+
const drag = (i: number, dx: number, dy: number) =>
|
|
491
|
+
page.evaluate(
|
|
492
|
+
([i, dx, dy]) => {
|
|
493
|
+
// No such handle (`bad-handle`): nothing to drag, sizes unchanged.
|
|
494
|
+
const el = document.querySelector<HTMLElement>(`[data-xui-id="rz.handle.${i}"]`)
|
|
495
|
+
if (!el) return
|
|
496
|
+
const r = el.getBoundingClientRect()
|
|
497
|
+
const x = r.left + r.width / 2
|
|
498
|
+
const y = r.top + r.height / 2
|
|
499
|
+
const fire = (type: string, mx: number, my: number) => el.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, composed: true, pointerId: 7, isPrimary: true, button: 0, buttons: type === `pointerup` ? 0 : 1, clientX: x + mx, clientY: y + my }))
|
|
500
|
+
fire(`pointerdown`, 0, 0)
|
|
501
|
+
fire(`pointermove`, dx, dy)
|
|
502
|
+
fire(`pointerup`, dx, dy)
|
|
503
|
+
},
|
|
504
|
+
[i, dx, dy] as const
|
|
505
|
+
)
|
|
506
|
+
for (const c of rz.normalize as { name: string; sizes: unknown; count: number; panels?: unknown[]; expected: number[] }[]) {
|
|
507
|
+
await mount({ id: `rz`, component: `Resizable`, props: { sizes: c.sizes, ...(c.panels ? { panels: c.panels } : {}) }, style: { width: 401, height: 60 }, children: panelsOf(c.count) })
|
|
508
|
+
const got = await sizes(c.count)
|
|
509
|
+
record(`resizable`, `normalize: ${c.name}`, near(got, c.expected), canon(got))
|
|
510
|
+
}
|
|
511
|
+
for (const c of rz.resize as { name: string; sizes: number[]; handle: number; delta: number; panels?: unknown[]; expected: number[] }[]) {
|
|
512
|
+
// 400 px of panels: 1 % = 4 px.
|
|
513
|
+
const n = c.sizes.length
|
|
514
|
+
await mount({ id: `rz`, component: `Resizable`, props: { sizes: c.sizes, ...(c.panels ? { panels: c.panels } : {}) }, style: { width: 400 + (n - 1), height: 60 }, children: panelsOf(n) }, { hairline: 1 })
|
|
515
|
+
await drag(c.handle, c.delta * 4, 0)
|
|
516
|
+
await frames()
|
|
517
|
+
const got = await sizes(n)
|
|
518
|
+
record(`resizable`, `resize: ${c.name}`, near(got, c.expected), canon(got))
|
|
519
|
+
}
|
|
520
|
+
for (const c of rz.keys as { name: string; sizes: number[]; handle: number; key: string; orientation: `horizontal` | `vertical`; direction: `ltr` | `rtl`; panels?: unknown[]; expected: number[] }[]) {
|
|
521
|
+
const n = c.sizes.length
|
|
522
|
+
await mount({ id: `rz`, component: `Resizable`, props: { sizes: c.sizes, direction: c.orientation, ...(c.panels ? { panels: c.panels } : {}) }, style: { width: 401, height: 401 }, children: panelsOf(n) }, { dir: c.direction })
|
|
523
|
+
await page.focus(`[data-xui-id="rz.handle.${c.handle}"]`)
|
|
524
|
+
await page.keyboard.press(c.key)
|
|
525
|
+
await frames()
|
|
526
|
+
const got = await sizes(n)
|
|
527
|
+
record(`resizable`, `keys: ${c.name}`, near(got, c.expected), canon(got))
|
|
528
|
+
}
|
|
529
|
+
for (const c of rz.extents as { name: string; sizes: number[]; container: number; handle: number; expected: number[] }[]) {
|
|
530
|
+
const n = c.sizes.length
|
|
531
|
+
await mount({ id: `rz`, component: `Resizable`, props: { sizes: c.sizes }, style: { width: c.container, height: 60 }, children: panelsOf(n) }, { hairline: c.handle })
|
|
532
|
+
const got = await page.evaluate((n) => Array.from({ length: n }, (_, i) => document.querySelector(`[data-xui-id="rz.panel.${i}"]`)!.getBoundingClientRect().width), n)
|
|
533
|
+
// CSS lays out in 1/64 px.
|
|
534
|
+
record(`resizable`, `extents: ${c.name}`, near(got, c.expected, 1 / 64), canon(got))
|
|
535
|
+
}
|
|
536
|
+
for (const c of rz.drag as { name: string; px: number; container: number; panels: number; orientation: `horizontal` | `vertical`; direction: `ltr` | `rtl`; expected: number }[]) {
|
|
537
|
+
const even = Array.from({ length: c.panels }, () => 100 / c.panels)
|
|
538
|
+
const vertical = c.orientation === `vertical`
|
|
539
|
+
await mount({ id: `rz`, component: `Resizable`, props: { sizes: even, direction: c.orientation }, style: vertical ? { width: 200, height: c.container } : { width: c.container, height: 60 }, children: panelsOf(c.panels) }, { dir: c.direction, hairline: 1 })
|
|
540
|
+
const before = await sizes(c.panels)
|
|
541
|
+
await drag(0, vertical ? 0 : c.px, vertical ? c.px : 0)
|
|
542
|
+
await frames()
|
|
543
|
+
const got = (await sizes(c.panels))[0]! - before[0]!
|
|
544
|
+
record(`resizable`, `drag: ${c.name}`, Math.abs(got - c.expected) <= 1e-6, String(got))
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// virtual-list: a bare WindowedList in a scroller of `viewport` px.
|
|
548
|
+
const vl = fixture(`virtual-list.json`)
|
|
549
|
+
const expand = (e: number[] | { count: number; extent: number }) => (Array.isArray(e) ? e : Array.from({ length: e.count }, () => e.extent))
|
|
550
|
+
const vlist = (c: { extents: number[]; gap: number; viewport: number; overscan?: number; axis?: string; stickyRows?: number[] }, act: string, a: unknown, b?: unknown) =>
|
|
551
|
+
page.evaluate(
|
|
552
|
+
async ([c, act, a, b]) => {
|
|
553
|
+
const probe = await (window as unknown as XuiProbes).__xuiVList!(c as never)
|
|
554
|
+
if (act === `window`) return probe.scroll(a as number)
|
|
555
|
+
await probe.scroll(a as number)
|
|
556
|
+
return probe.scrollToIndex((b as { index: number }).index, (b as { align: `start` }).align)
|
|
557
|
+
},
|
|
558
|
+
[c, act, a, b] as const
|
|
559
|
+
)
|
|
560
|
+
for (const c of vl.windows as { name: string; extents: number[] | { count: number; extent: number }; gap: number; scroll: number; viewport: number; overscan?: number; expected: Record<string, number> }[]) {
|
|
561
|
+
const got = (await vlist({ extents: expand(c.extents), gap: c.gap, viewport: c.viewport, overscan: c.overscan, axis: c.name.includes(`horizontal`) ? `horizontal` : `vertical` }, `window`, c.scroll)) as unknown as Record<string, number>
|
|
562
|
+
const keys = [`start`, `end`, `before`, `after`, `total`]
|
|
563
|
+
record(`virtual-list`, `window: ${c.name}`, keys.every((k) => Math.abs(got[k]! - c.expected[k]!) <= 0.5), canon(got))
|
|
564
|
+
}
|
|
565
|
+
for (const c of vl.scrollTo as { name: string; extents: number[] | { count: number; extent: number }; gap: number; index: number; viewport: number; scroll: number; align: `start`; inset?: number; expected: number }[]) {
|
|
566
|
+
if (c.inset) {
|
|
567
|
+
// An arbitrary inset is not paintable (WindowedList takes it from a
|
|
568
|
+
// pinned header row); the painter's jump calls this function.
|
|
569
|
+
record(`virtual-list`, `scrollTo: ${c.name}`, scrollOffsetForIndex(expand(c.extents), c.gap, c.index, c.viewport, c.scroll, c.align, c.inset) === c.expected)
|
|
570
|
+
continue
|
|
571
|
+
}
|
|
572
|
+
const got = (await vlist({ extents: expand(c.extents), gap: c.gap, viewport: c.viewport }, `scrollTo`, c.scroll, { index: c.index, align: c.align })) as number
|
|
573
|
+
record(`virtual-list`, `scrollTo: ${c.name}`, Math.abs(got - c.expected) <= 0.5, String(got))
|
|
574
|
+
}
|
|
575
|
+
for (const c of vl.sticky as { name: string; rowExtents: number[]; headerRows: number[]; scrolls: number[]; expected: unknown[] }[]) {
|
|
576
|
+
const got: unknown[] = []
|
|
577
|
+
for (const s of c.scrolls) got.push(((await vlist({ extents: c.rowExtents, gap: 0, viewport: 60, stickyRows: c.headerRows }, `window`, s)) as { pinned: unknown }).pinned)
|
|
578
|
+
record(`virtual-list`, `sticky: ${c.name}`, canon(got) === canon(c.expected), canon(got))
|
|
579
|
+
}
|
|
580
|
+
// Sections: a sectioned List painted flat, its rows read back.
|
|
581
|
+
for (const c of vl.sections as { name: string; items: Record<string, unknown>[]; sectionBy: string; expected: unknown }[]) {
|
|
582
|
+
await mount({ id: `L`, component: `List`, props: { sectionBy: c.sectionBy }, template: { component: `L-row`, path: `/items`, key: `id` }, children: [{ id: `L-row`, component: `Text`, props: { text: { path: `id` } } }] }, { data: { items: c.items } })
|
|
583
|
+
const got = await page.evaluate((ids) => {
|
|
584
|
+
const rows: ({ header: number } | { item: number })[] = []
|
|
585
|
+
const sections: { value: string; start: number; count: number }[] = []
|
|
586
|
+
for (const row of Array.from(document.querySelectorAll<HTMLElement>(`[data-xui-id="L"] > .xui-list-row`))) {
|
|
587
|
+
const head = row.querySelector<HTMLElement>(`[data-xui-id^="L.section."]`)
|
|
588
|
+
if (head) {
|
|
589
|
+
rows.push({ header: sections.length })
|
|
590
|
+
sections.push({ value: head.textContent ?? ``, start: 0, count: 0 })
|
|
591
|
+
continue
|
|
592
|
+
}
|
|
593
|
+
const item = ids.indexOf(row.querySelector<HTMLElement>(`[data-xui-c="Text"]`)!.textContent ?? ``)
|
|
594
|
+
rows.push({ item })
|
|
595
|
+
const s = sections[sections.length - 1]
|
|
596
|
+
if (s.count === 0) s.start = item
|
|
597
|
+
s.count += 1
|
|
598
|
+
}
|
|
599
|
+
return { sections, rows }
|
|
600
|
+
}, c.items.map((i) => String(i.id)))
|
|
601
|
+
record(`virtual-list`, `sections: ${c.name}`, canon(got) === canon(c.expected), canon(got))
|
|
602
|
+
}
|
|
603
|
+
for (const c of vl.sectionedScrollTo as { name: string; index: number; viewport: number; scroll: number; align: `start`; stickyHeaders: boolean; items: Record<string, unknown>[]; sectionBy: string; headerExtent: number; itemExtent: number; expected: number }[]) {
|
|
604
|
+
await mount(
|
|
605
|
+
{ id: `L`, component: `List`, props: { sectionBy: c.sectionBy, stickyHeaders: c.stickyHeaders }, style: { height: c.viewport }, template: { component: `L-row`, path: `/items`, key: `id` }, slots: { section: { id: `L-head`, component: `Text`, props: { text: { path: `value` } }, style: { height: c.headerExtent } } }, children: [{ id: `L-row`, component: `Text`, props: { text: { path: `id` } }, style: { height: c.itemExtent } }] },
|
|
606
|
+
{ data: { items: c.items }, width: 300 }
|
|
607
|
+
)
|
|
608
|
+
const got = await page.evaluate(
|
|
609
|
+
async ([scroll, index, align]) => {
|
|
610
|
+
const list = document.querySelector<HTMLElement>(`[data-xui-id="L"]`)!
|
|
611
|
+
list.scrollTop = scroll as number
|
|
612
|
+
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
|
|
613
|
+
const probes = window as unknown as XuiProbes
|
|
614
|
+
probes.__xuiHandle!()!.scrollToIndex(`L`, index as number, align as `start`)
|
|
615
|
+
for (let i = 0; i < 8; i++) await new Promise((r) => requestAnimationFrame(r))
|
|
616
|
+
return list.scrollTop
|
|
617
|
+
},
|
|
618
|
+
[c.scroll, c.index, c.align] as const
|
|
619
|
+
)
|
|
620
|
+
record(`virtual-list`, `sectionedScrollTo: ${c.name}`, Math.abs(got - c.expected) <= 0.5, String(got))
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// animations: the CSS this renderer paints per theme (duration, easing,
|
|
624
|
+
// iterations off the computed style); the frames are the keyframes the
|
|
625
|
+
// base sheet ships (each case checks them against the fixture), sampled by
|
|
626
|
+
// the core.
|
|
627
|
+
const anim = fixture(`animations.json`)
|
|
628
|
+
for (const [id, entries] of Object.entries(anim.themes as Record<string, Record<string, { timing: { durationMs: number; easing: number[] | string; iterations: number | string }; frames: { t: number; frame: Record<string, number | null> }[]; reduced: unknown }>>))
|
|
629
|
+
for (const [name, e] of Object.entries(entries)) {
|
|
630
|
+
await mount({ id: `a`, component: `Box`, style: { width: 40, height: 40, animation: name } }, { theme: id })
|
|
631
|
+
const css = await page.evaluate(() => {
|
|
632
|
+
const cs = getComputedStyle(document.querySelector(`[data-xui-id="a"]`)!)
|
|
633
|
+
return { duration: cs.animationDuration, easing: cs.animationTimingFunction, iterations: cs.animationIterationCount }
|
|
634
|
+
})
|
|
635
|
+
const theme = builtinTheme(id)
|
|
636
|
+
const ms = css.duration.endsWith(`ms`) ? Number.parseFloat(css.duration) : Number.parseFloat(css.duration) * 1000
|
|
637
|
+
const easing = Array.isArray(e.timing.easing) ? `cubic-bezier(${e.timing.easing.join(`, `)})` : e.timing.easing
|
|
638
|
+
const verdict = (() => {
|
|
639
|
+
// The keyframes + @property rules this renderer ships (base-css.ts) are the fixture's.
|
|
640
|
+
if (keyframesCss(name) !== (anim.css as Record<string, string>)[name] || !BASE_CSS.includes(keyframesCss(name)) || !BASE_CSS.includes(anim.properties as string)) return `css`
|
|
641
|
+
if (Math.abs(ms - e.timing.durationMs) > 0.5) return `duration ${css.duration}`
|
|
642
|
+
if (css.easing !== easing) return `easing ${css.easing}`
|
|
643
|
+
if (css.iterations !== String(e.timing.iterations)) return `iterations ${css.iterations}`
|
|
644
|
+
for (const fr of e.frames) {
|
|
645
|
+
const got = animationFrame(name, fr.t, theme) as unknown as Record<string, number | null>
|
|
646
|
+
for (const [k, v] of Object.entries(fr.frame)) if (v === null ? got[k] !== null : Math.abs((got[k] as number) - v) > 1e-3) return `${fr.t}.${k}`
|
|
647
|
+
}
|
|
648
|
+
return canon(animationFrame(name, 5, theme, { reducedMotion: true })) === canon(e.reduced) ? true : `reduced`
|
|
649
|
+
})()
|
|
650
|
+
record(`animations`, `${id}/${name}`, verdict)
|
|
651
|
+
}
|
|
652
|
+
/** The painted opacity of node `a` with its animation paused at `t` ms. */
|
|
653
|
+
const opacityAt = (t: number) =>
|
|
654
|
+
page.evaluate((t) => {
|
|
655
|
+
const el = document.querySelector<HTMLElement>(`[data-xui-id="a"]`)!
|
|
656
|
+
for (const a of el.getAnimations()) {
|
|
657
|
+
a.pause()
|
|
658
|
+
a.currentTime = t
|
|
659
|
+
}
|
|
660
|
+
return Number(getComputedStyle(el).opacity)
|
|
661
|
+
}, t)
|
|
662
|
+
// `override`: animationDuration keeps the set's factor; the frame painted.
|
|
663
|
+
{
|
|
664
|
+
const o = anim.override as { theme: string; name: string; durationToken: string; timing: { durationMs: number }; frame: { opacity: number } }
|
|
665
|
+
await mount({ id: `a`, component: `Box`, style: { width: 40, height: 40, animation: o.name, animationDuration: o.durationToken } }, { theme: o.theme })
|
|
666
|
+
const duration = await page.evaluate(() => getComputedStyle(document.querySelector(`[data-xui-id="a"]`)!).animationDuration)
|
|
667
|
+
const ms = duration.endsWith(`ms`) ? Number.parseFloat(duration) : Number.parseFloat(duration) * 1000
|
|
668
|
+
const painted = await opacityAt(o.timing.durationMs / 2)
|
|
669
|
+
record(`animations`, `override`, Math.abs(ms - o.timing.durationMs) <= 0.5 && Math.abs(painted - o.frame.opacity) <= 1e-3, `${duration} ${painted}`)
|
|
670
|
+
}
|
|
671
|
+
// `opacity`: the painted opacity = the node's own × the frame's.
|
|
672
|
+
for (const c of anim.opacity as { name: string; own: number; animation: string; theme: string; t: number; expected: number }[]) {
|
|
673
|
+
await mount({ id: `a`, component: `Box`, style: { width: 40, height: 40, opacity: c.own, animation: c.animation } }, { theme: c.theme })
|
|
674
|
+
const painted = await opacityAt(c.t)
|
|
675
|
+
record(`animations`, `opacity: ${c.name}`, Math.abs(painted - c.expected) <= 1e-3, String(painted))
|
|
676
|
+
}
|
|
677
|
+
await context.close()
|
|
678
|
+
})
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
void BUILTIN_THEMES
|
|
683
|
+
void CORE_CATALOG_ID
|