@hyperframes/studio 0.8.190 → 0.8.191

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.190",
3
+ "version": "0.8.191",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -59,12 +59,12 @@
59
59
  "dockview-react": "8.3.1",
60
60
  "gsap": "^3.13.0",
61
61
  "mediabunny": "^1.45.3",
62
- "@hyperframes/core": "0.8.190",
63
- "@hyperframes/parsers": "0.8.190",
64
- "@hyperframes/sdk": "0.8.190",
65
- "@hyperframes/player": "0.8.190",
66
- "@hyperframes/shader-transitions": "0.8.190",
67
- "@hyperframes/studio-server": "0.8.190"
62
+ "@hyperframes/core": "0.8.191",
63
+ "@hyperframes/parsers": "0.8.191",
64
+ "@hyperframes/player": "0.8.191",
65
+ "@hyperframes/shader-transitions": "0.8.191",
66
+ "@hyperframes/studio-server": "0.8.191",
67
+ "@hyperframes/sdk": "0.8.191"
68
68
  },
69
69
  "devDependencies": {
70
70
  "@tailwindcss/vite": "^4.3.3",
@@ -81,7 +81,7 @@
81
81
  "vite": "^6.4.2",
82
82
  "vitest": "^4.1.11",
83
83
  "zustand": "^5.0.0",
84
- "@hyperframes/producer": "0.8.190"
84
+ "@hyperframes/producer": "0.8.191"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "react": "19",
@@ -0,0 +1,126 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it } from "vitest";
3
+ import { generateCaptionHtml } from "./generator";
4
+ import { parseCaptionComposition } from "./parser";
5
+
6
+ const transcript = "We asked what you needed. Forty-seven percent".split(" ").map((text, i) => ({
7
+ id: `word-${i}`,
8
+ text,
9
+ start: i * 0.5,
10
+ end: i * 0.5 + 0.4,
11
+ }));
12
+ const source = `const TRANSCRIPT = ${JSON.stringify(transcript)};`;
13
+
14
+ function createCaptionDocument(
15
+ groupSizes: number[],
16
+ groupClass = "caption-group",
17
+ wordClass = "word",
18
+ ) {
19
+ const doc = document.implementation.createHTMLDocument();
20
+ let offset = 0;
21
+ for (const size of groupSizes) {
22
+ const group = doc.createElement("div");
23
+ group.className = groupClass;
24
+ for (const word of transcript.slice(offset, offset + size)) {
25
+ const span = doc.createElement("span");
26
+ span.className = wordClass;
27
+ span.textContent = word.text;
28
+ group.appendChild(span);
29
+ }
30
+ doc.body.appendChild(group);
31
+ offset += size;
32
+ }
33
+ return doc;
34
+ }
35
+
36
+ function importCaptions(doc: Document, captionSource = source) {
37
+ const model = parseCaptionComposition(doc, window, captionSource, 1920, 1080, 4);
38
+ expect(model).not.toBeNull();
39
+ if (!model) throw new Error("Expected a caption model");
40
+ const groups = model.groupOrder.map((id) =>
41
+ model.groups.get(id)?.segmentIds.map((segmentId) => model.segments.get(segmentId)?.text),
42
+ );
43
+ return { model, groups };
44
+ }
45
+
46
+ describe("parseCaptionComposition grouping", () => {
47
+ it.each([{ sizes: [5, 2] }, { sizes: [2, 4, 1] }])(
48
+ "preserves authored group sizes $sizes",
49
+ ({ sizes }) => {
50
+ const { model, groups } = importCaptions(createCaptionDocument(sizes));
51
+ expect(groups.map((group) => group?.length)).toEqual(sizes);
52
+ expect(groups.flat()).toEqual(transcript.map((word) => word.text));
53
+ expect(
54
+ [...model.segments.values()].map(({ wordId, text, start, end }) => ({
55
+ id: wordId,
56
+ text,
57
+ start,
58
+ end,
59
+ })),
60
+ ).toEqual(transcript);
61
+ for (const group of model.groups.values()) {
62
+ expect(group.segmentIds.map((id) => model.segments.get(id)?.groupIndex)).toEqual(
63
+ group.segmentIds.map((_, index) => index),
64
+ );
65
+ }
66
+ },
67
+ );
68
+
69
+ it.each(["caption-line", "caption-block"])(
70
+ "supports %s with caption-word spans",
71
+ (groupClass) => {
72
+ const { groups } = importCaptions(
73
+ createCaptionDocument([2, 4, 1], groupClass, "caption-word"),
74
+ );
75
+ expect(groups.map((group) => group?.length)).toEqual([2, 4, 1]);
76
+ },
77
+ );
78
+
79
+ it("does not count nested caption lines as additional groups", () => {
80
+ const doc = createCaptionDocument([2, 4, 1]);
81
+ for (const group of doc.querySelectorAll(".caption-group")) {
82
+ const line = doc.createElement("div");
83
+ line.className = "caption-line";
84
+ line.append(...group.childNodes);
85
+ group.appendChild(line);
86
+ }
87
+ expect(importCaptions(doc).groups.map((group) => group?.length)).toEqual([2, 4, 1]);
88
+ });
89
+
90
+ it.each([
91
+ { name: "absent", sizes: [] },
92
+ { name: "incomplete", sizes: [2, 1] },
93
+ { name: "empty", sizes: [0, 0] },
94
+ ])("uses default grouping without losing words when DOM groups are $name", ({ sizes }) => {
95
+ const { groups } = importCaptions(createCaptionDocument(sizes));
96
+ expect(groups).toEqual([
97
+ transcript.slice(0, 5).map((word) => word.text),
98
+ transcript.slice(5).map((word) => word.text),
99
+ ]);
100
+ });
101
+
102
+ it("keeps authored boundaries when generated captions are imported again", () => {
103
+ const original = importCaptions(createCaptionDocument([2, 4, 1]));
104
+ const html = generateCaptionHtml(original.model);
105
+ const doc = document.implementation.createHTMLDocument();
106
+ doc.body.innerHTML = html;
107
+ const template = doc.querySelector("template");
108
+ if (!template) throw new Error("Expected generated caption template");
109
+ doc.body.replaceChildren(template.content.cloneNode(true));
110
+ const script = doc.querySelector("script")?.textContent;
111
+ if (!script) throw new Error("Expected generated caption script");
112
+ // Execute only our generator's output to create its real group and word elements.
113
+ new Function("document", "window", "gsap", script)(
114
+ doc,
115
+ {},
116
+ {
117
+ timeline: () => ({ to() {} }),
118
+ },
119
+ );
120
+ expect(importCaptions(doc, html).groups).toEqual([
121
+ transcript.slice(0, 2).map((word) => word.text),
122
+ transcript.slice(2, 6).map((word) => word.text),
123
+ transcript.slice(6).map((word) => word.text),
124
+ ]);
125
+ });
126
+ });
@@ -38,23 +38,32 @@ export function buildCaptionModel(
38
38
  transcript: TranscriptWord[],
39
39
  options: BuildOptions,
40
40
  ): CaptionModel {
41
- const { width, height, duration, wordsPerGroup = 5 } = options;
41
+ const { wordsPerGroup = 5 } = options;
42
+ const wordGroups: TranscriptWord[][] = [];
43
+ for (let offset = 0; offset < transcript.length; offset += wordsPerGroup) {
44
+ wordGroups.push(transcript.slice(offset, offset + wordsPerGroup));
45
+ }
46
+ return buildGroupedCaptionModel(wordGroups, options);
47
+ }
42
48
 
49
+ function buildGroupedCaptionModel(
50
+ wordGroups: TranscriptWord[][],
51
+ { width, height, duration }: BuildOptions,
52
+ ): CaptionModel {
43
53
  const segments = new Map<string, CaptionSegment>();
44
54
  const groups = new Map<string, CaptionGroup>();
45
55
  const groupOrder: string[] = [];
46
56
 
47
- // Chunk the transcript into groups of wordsPerGroup
48
- for (let groupIdx = 0; groupIdx < transcript.length; groupIdx += wordsPerGroup) {
49
- const chunk = transcript.slice(groupIdx, groupIdx + wordsPerGroup);
50
- const groupId = `group-${groupIdx / wordsPerGroup}`;
57
+ for (const chunk of wordGroups) {
58
+ const groupId = `group-${groups.size}`;
51
59
  const segmentIds: string[] = [];
52
60
 
53
61
  chunk.forEach((word, wordIdx) => {
54
- const segmentId = `segment-${groupIdx + wordIdx}`;
62
+ const segmentIndex = segments.size;
63
+ const segmentId = `segment-${segmentIndex}`;
55
64
  const segment: CaptionSegment = {
56
65
  id: segmentId,
57
- wordId: word.id ?? `w${groupIdx + wordIdx}`,
66
+ wordId: word.id ?? `w${segmentIndex}`,
58
67
  text: word.text,
59
68
  start: word.start,
60
69
  end: word.end,
@@ -96,6 +105,24 @@ export function buildCaptionModel(
96
105
  };
97
106
  }
98
107
 
108
+ const GROUP_SELECTOR = ".caption-group, .caption-line, .caption-block";
109
+ const WORD_SELECTOR = ".word, .caption-word";
110
+
111
+ /** Read outer groups in transcript order; return null if their word counts do not match. */
112
+ function readTranscriptGroups(transcript: TranscriptWord[], groupEls: NodeListOf<Element>) {
113
+ const wordGroups: TranscriptWord[][] = [];
114
+ let offset = 0;
115
+ for (const group of groupEls) {
116
+ // Caption templates can nest layout lines inside a single timed group.
117
+ if (group.parentElement?.closest(GROUP_SELECTOR)) continue;
118
+ const size = group.querySelectorAll(WORD_SELECTOR).length;
119
+ if (size === 0) continue;
120
+ wordGroups.push(transcript.slice(offset, offset + size));
121
+ offset += size;
122
+ }
123
+ return offset === transcript.length ? wordGroups : null;
124
+ }
125
+
99
126
  /**
100
127
  * Extracts a transcript word array from caption composition source code.
101
128
  *
@@ -130,7 +157,8 @@ export function extractTranscript(source: string): TranscriptWord[] {
130
157
  * from the source and reading computed styles from rendered elements.
131
158
  *
132
159
  * Runs in the Studio (outside the iframe). Reads computed styles from iframe DOM
133
- * elements to build a fully-styled CaptionModel.
160
+ * elements to build a fully-styled CaptionModel. Preserves DOM group boundaries
161
+ * when they account for every transcript word; otherwise uses default grouping.
134
162
  *
135
163
  * Returns null if no transcript is found in the source.
136
164
  */
@@ -149,25 +177,19 @@ export function parseCaptionComposition(
149
177
  }
150
178
 
151
179
  // Step 2: Look for grouping and word elements in the iframe DOM
152
- const groupEls = iframeDoc.querySelectorAll(".caption-group, .caption-line, .caption-block");
153
- const wordEls = iframeDoc.querySelectorAll(".word, .caption-word");
154
-
155
- // Step 3: Infer wordsPerGroup from element counts
156
- let wordsPerGroup = 5; // default
157
- if (groupEls.length > 0 && wordEls.length > 0) {
158
- wordsPerGroup = Math.round(wordEls.length / groupEls.length);
159
- if (wordsPerGroup < 1) {
160
- wordsPerGroup = 1;
161
- }
162
- }
180
+ const groupEls = iframeDoc.querySelectorAll(GROUP_SELECTOR);
181
+ const wordEls = iframeDoc.querySelectorAll(WORD_SELECTOR);
163
182
 
164
- // Step 4: Build the caption model with inferred grouping
165
- const model = buildCaptionModel(transcript, {
183
+ // Preserve authored boundaries when the DOM accounts for the complete transcript.
184
+ const wordGroups = readTranscriptGroups(transcript, groupEls);
185
+ const options = {
166
186
  width: compositionWidth,
167
187
  height: compositionHeight,
168
188
  duration: compositionDuration,
169
- wordsPerGroup,
170
- });
189
+ };
190
+ const model = wordGroups
191
+ ? buildGroupedCaptionModel(wordGroups, options)
192
+ : buildCaptionModel(transcript, options);
171
193
 
172
194
  // Step 5: Read computed styles from the first word or group element
173
195
  const firstWordEl = wordEls.item(0) as Element | null;
@@ -8,6 +8,7 @@ import { BlockParamsPanel } from "./editor/BlockParamsPanel";
8
8
  import { RenderQueuePanel } from "./renders/RenderQueuePanel";
9
9
  import { SlideshowPanel } from "./panels/SlideshowPanel";
10
10
  import { VariablesPanel } from "./panels/VariablesPanel";
11
+ import { MotionBlurPanel } from "./panels/MotionBlurPanel";
11
12
  import { Dock } from "./dock/Dock";
12
13
  import { useDockLayoutStore } from "./dock/dockLayoutStore";
13
14
  import type { RenderJob } from "./renders/useRenderQueue";
@@ -300,6 +301,14 @@ export const StudioRightPanels = memo(function StudioRightPanels({
300
301
  recordEdit={recordEdit}
301
302
  />
302
303
  </Dock.Panel>
304
+ <Dock.Panel id="motionBlur">
305
+ <MotionBlurPanel
306
+ sdkSession={sdkSession}
307
+ publishSdkSession={publishSdkSession}
308
+ reloadPreview={reloadPreview}
309
+ recordEdit={recordEdit}
310
+ />
311
+ </Dock.Panel>
303
312
  <Dock.Panel id="slideshow">
304
313
  <SlideshowPanel
305
314
  scenes={slideshowScenes}
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useSyncExternalStore } from "react";
2
2
  import {
3
+ Aperture,
3
4
  BracketsCurly,
4
5
  ChartBarHorizontal,
5
6
  Code,
@@ -29,6 +30,7 @@ const TAB_ICONS: Record<PanelId, Icon> = {
29
30
  renders: FilmSlate,
30
31
  variables: BracketsCurly,
31
32
  slideshow: Presentation,
33
+ motionBlur: Aperture,
32
34
  };
33
35
 
34
36
  /**
@@ -158,9 +158,9 @@ describe("dock tab strips", () => {
158
158
  it("jump to the first and last tab with Home and End", () => {
159
159
  const tabs = tabsOf("design");
160
160
  press(tabs[0] as HTMLElement, "End");
161
- expect(api.getPanel("design")?.group.activePanel?.id).toBe("variables");
162
- expect(document.activeElement).toBe(tabs[3]);
163
- press(tabs[3] as HTMLElement, "Home");
161
+ expect(api.getPanel("design")?.group.activePanel?.id).toBe("motionBlur");
162
+ expect(document.activeElement).toBe(tabs[4]);
163
+ press(tabs[4] as HTMLElement, "Home");
164
164
  expect(api.getPanel("design")?.group.activePanel?.id).toBe("design");
165
165
  });
166
166
  });
@@ -9,7 +9,7 @@ const MIN_SIDE_W = 200;
9
9
  const MIN_SIDE_W_FLOOR = 120;
10
10
  const DEFAULT_TIMELINE_H = 360;
11
11
  const DEFAULT_LEFT = ["compositions", "assets", "code", "catalog"] as const;
12
- const DEFAULT_RIGHT = ["design", "layers", "renders", "variables"] as const;
12
+ const DEFAULT_RIGHT = ["design", "layers", "renders", "variables", "motionBlur"] as const;
13
13
 
14
14
  /** Preferred side widths; when they overflow the preview's floor the right yields first, then the left. */
15
15
  export function defaultSideWidths(viewportWidth: number) {
@@ -12,6 +12,7 @@ export const PANEL_IDS = [
12
12
  "renders",
13
13
  "variables",
14
14
  "slideshow",
15
+ "motionBlur",
15
16
  ] as const;
16
17
 
17
18
  export type PanelId = (typeof PANEL_IDS)[number];
@@ -56,6 +57,11 @@ export const PANEL_DEFINITIONS = {
56
57
  renders: { title: "Renders", zone: "right", reopen: { near: "design", direction: "within" } },
57
58
  variables: { title: "Variables", zone: "right", reopen: { near: "design", direction: "within" } },
58
59
  slideshow: { title: "Slideshow", zone: "right", reopen: { near: "design", direction: "within" } },
60
+ motionBlur: {
61
+ title: "Blur",
62
+ zone: "right",
63
+ reopen: { near: "design", direction: "within" },
64
+ },
59
65
  } as const satisfies Record<PanelId, PanelDefinition>;
60
66
 
61
67
  export function isPanelId(value: unknown): value is PanelId {
@@ -0,0 +1,177 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { openComposition } from "@hyperframes/sdk";
7
+ import { MotionBlurPanel } from "./MotionBlurPanel";
8
+
9
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ const page = (attrs = "") =>
12
+ `<!DOCTYPE html><html><body><div data-composition-id="main" data-hf-root data-duration="5" data-width="1920" data-height="1080"${attrs}></div></body></html>`;
13
+
14
+ const shell = {
15
+ activeCompPath: null as string | null,
16
+ previewIframeRef: { current: null as HTMLIFrameElement | null },
17
+ showToast: vi.fn(),
18
+ };
19
+
20
+ vi.mock("../../contexts/StudioContext", () => ({
21
+ useStudioShellContext: () => shell,
22
+ useStudioPlaybackContext: () => ({ refreshKey: 0 }),
23
+ }));
24
+
25
+ const files: Record<string, string> = {};
26
+ const writeProjectFile = vi.fn(async (path: string, content: string) => {
27
+ files[path] = content;
28
+ });
29
+ vi.mock("../../contexts/FileManagerContext", () => ({
30
+ useFileManagerContext: () => ({
31
+ readProjectFile: async (path: string) => files[path] ?? "",
32
+ writeProjectFile,
33
+ compositions: ["index.html", "scene.html"],
34
+ }),
35
+ }));
36
+
37
+ let root: Root | null = null;
38
+ const recordEdit = vi.fn(async () => {});
39
+
40
+ function cleanup() {
41
+ act(() => root?.unmount());
42
+ root = null;
43
+ document.body.innerHTML = "";
44
+ }
45
+
46
+ afterEach(() => {
47
+ cleanup();
48
+ shell.activeCompPath = null;
49
+ shell.previewIframeRef.current = null;
50
+ vi.clearAllMocks();
51
+ });
52
+
53
+ async function mount(attrs = "") {
54
+ files["index.html"] = page(attrs);
55
+ const host = document.createElement("div");
56
+ document.body.append(host);
57
+ root = createRoot(host);
58
+ const session = await openComposition(files["index.html"], { history: false });
59
+ await act(async () => {
60
+ root?.render(
61
+ <MotionBlurPanel
62
+ sdkSession={session}
63
+ publishSdkSession={vi.fn()}
64
+ reloadPreview={vi.fn()}
65
+ recordEdit={recordEdit}
66
+ />,
67
+ );
68
+ });
69
+ return host;
70
+ }
71
+
72
+ const settle = () => act(async () => void (await new Promise((r) => setTimeout(r, 0))));
73
+ const click = async (el: Element | null) => {
74
+ await act(async () => (el as HTMLElement).click());
75
+ await settle();
76
+ };
77
+
78
+ const radio = (host: HTMLElement, label: string) =>
79
+ [...host.querySelectorAll('[role="radio"]')].find((el) => el.textContent === label) ?? null;
80
+ const checked = (host: HTMLElement) =>
81
+ [...host.querySelectorAll('[role="radio"]')]
82
+ .filter((el) => el.getAttribute("aria-checked") === "true")
83
+ .map((el) => el.textContent);
84
+
85
+ describe("MotionBlurPanel", () => {
86
+ it("offers Off, Subtle and Strong and nothing else", async () => {
87
+ const host = await mount();
88
+ expect([...host.querySelectorAll('[role="radio"]')].map((el) => el.textContent)).toEqual([
89
+ "Off",
90
+ "Subtle",
91
+ "Strong",
92
+ ]);
93
+ expect(
94
+ host.querySelectorAll('[role="slider"], [role="switch"], [role="combobox"], input'),
95
+ ).toHaveLength(0);
96
+ });
97
+
98
+ it("shows the level the root is at", async () => {
99
+ expect(checked(await mount())).toEqual(["Off"]);
100
+ cleanup();
101
+ expect(checked(await mount(' data-motion-blur="on"'))).toEqual(["Subtle"]);
102
+ cleanup();
103
+ expect(
104
+ checked(
105
+ await mount(
106
+ ' data-motion-blur="on" data-motion-blur-shutter="720" data-motion-blur-samples="64"',
107
+ ),
108
+ ),
109
+ ).toEqual(["Strong"]);
110
+ });
111
+
112
+ it("Subtle writes data-motion-blur and leaves the default shutter off the root", async () => {
113
+ const host = await mount(
114
+ ' data-motion-blur="on" data-motion-blur-shutter="720" data-motion-blur-samples="64"',
115
+ );
116
+ await click(radio(host, "Subtle"));
117
+ const html = files["index.html"] ?? "";
118
+ expect(html).toContain('data-motion-blur="on"');
119
+ expect(html).not.toContain("data-motion-blur-shutter");
120
+ expect(html).not.toContain("data-motion-blur-samples");
121
+ expect(recordEdit).toHaveBeenCalledTimes(1);
122
+ });
123
+
124
+ it("Strong writes the maximum shutter and samples", async () => {
125
+ const host = await mount();
126
+ await click(radio(host, "Strong"));
127
+ const html = files["index.html"] ?? "";
128
+ expect(html).toContain('data-motion-blur="on"');
129
+ expect(html).toContain('data-motion-blur-shutter="720"');
130
+ expect(html).toContain('data-motion-blur-samples="64"');
131
+ });
132
+
133
+ it("Off removes data-motion-blur and leaves every other setting on the root", async () => {
134
+ const host = await mount(
135
+ ' data-motion-blur="on" data-motion-blur-shutter="720" data-motion-blur-samples="64" data-motion-blur-tier="filter" data-motion-blur-events="on"',
136
+ );
137
+ await click(radio(host, "Off"));
138
+ const html = files["index.html"] ?? "";
139
+ expect(html).not.toContain('data-motion-blur="on"');
140
+ for (const kept of ['shutter="720"', 'samples="64"', 'tier="filter"', 'events="on"']) {
141
+ expect(html).toContain(`data-motion-blur-${kept}`);
142
+ }
143
+ });
144
+
145
+ it("writes nothing when the chosen level is already set", async () => {
146
+ const host = await mount(' data-motion-blur="on"');
147
+ await click(radio(host, "Subtle"));
148
+ expect(writeProjectFile).not.toHaveBeenCalled();
149
+ });
150
+
151
+ it("follows the live preview over the session, so an undo or an outside edit shows", async () => {
152
+ const doc = document.implementation.createHTMLDocument("preview");
153
+ doc.body.innerHTML =
154
+ '<div data-composition-id="main" data-motion-blur="on" data-motion-blur-shutter="720"></div>';
155
+ shell.previewIframeRef.current = { contentDocument: doc } as unknown as HTMLIFrameElement;
156
+ expect(checked(await mount(' data-motion-blur="on"'))).toEqual(["Strong"]);
157
+ });
158
+
159
+ it("writes the level picked even when the preview and the file disagree", async () => {
160
+ const doc = document.implementation.createHTMLDocument("preview");
161
+ doc.body.innerHTML =
162
+ '<div data-composition-id="main" data-motion-blur="on" data-motion-blur-shutter="720" data-motion-blur-samples="64"></div>';
163
+ shell.previewIframeRef.current = { contentDocument: doc } as unknown as HTMLIFrameElement;
164
+ const host = await mount();
165
+ await click(radio(host, "Subtle"));
166
+ expect(files["index.html"]).toContain('data-motion-blur="on"');
167
+ expect(files["index.html"]).not.toContain("data-motion-blur-samples");
168
+ });
169
+
170
+ it("is read-only for a sub-composition, which blur does not read", async () => {
171
+ shell.activeCompPath = "scene.html";
172
+ const host = await mount();
173
+ expect(host.textContent).toContain("read from the main composition");
174
+ expect(radio(host, "Strong")?.hasAttribute("disabled")).toBe(true);
175
+ expect(checked(host)).toEqual([]);
176
+ });
177
+ });