diff --git a/resources/slam-ros-bridge/bridge.py b/resources/slam-ros-bridge/bridge.py index 5adfe70..296bd89 100644 --- a/resources/slam-ros-bridge/bridge.py +++ b/resources/slam-ros-bridge/bridge.py @@ -6,6 +6,7 @@ import json import logging import math import os +import re import shutil import sqlite3 import struct @@ -207,6 +208,8 @@ class SlamNode(Node): self._fallback_point_cloud: Optional[PointCloud2] = None self._color_point_cloud: Optional[PointCloud2] = None self._camera_image: Optional[CompressedImage] = None + self._camera_frames = {} + self._camera_subscriptions = {} self._icp_odom: Optional[Odometry] = None self._map: Optional[OccupancyGrid] = None self._plan: Optional[NavPath] = None @@ -261,6 +264,8 @@ class SlamNode(Node): self.create_subscription(CompressedImage, self.camera_image_topic, self._on_camera_image, qos_profile_sensor_data) if not os.environ.get("CAMERA_IMAGE_TOPIC"): self.create_subscription(CompressedImage, "/image_raw/compressed", self._on_camera_image, qos_profile_sensor_data) + self._camera_discovery_timer = self.create_timer( + 1.0, self._discover_cameras, clock=Clock(clock_type=ClockType.STEADY_TIME)) self.create_subscription(Odometry, self.icp_odom_topic, self._on_icp_odom, best_effort_qos) self.create_subscription(Odometry, self.icp_odom_topic, self._on_icp_odom, reliable_qos) self.create_subscription(OccupancyGrid, self.map_topic, self._on_map, map_qos) @@ -625,11 +630,45 @@ class SlamNode(Node): self._color_point_cloud_count += 1 self._last_color_point_cloud_wall_time = time.time() - def _on_camera_image(self, msg: CompressedImage): + def _discover_cameras(self): + try: + topics = sorted(name for name, types in self.get_topic_names_and_types() + if re.fullmatch(r'/camera_test/cameras/video[0-9]+/image_jpeg', name) + and 'sensor_msgs/msg/CompressedImage' in types)[:8] + except Exception: + return + for topic in topics: + camera_id = topic.split('/')[-2] + if camera_id not in self._camera_subscriptions: + self._camera_subscriptions[camera_id] = self.create_subscription( + CompressedImage, topic, + lambda msg, key=camera_id, name=topic: self._on_camera_image(msg, key, name), + qos_profile_sensor_data) + active = {topic.split('/')[-2] for topic in topics} + for camera_id in list(self._camera_subscriptions): + if camera_id not in active: + self.destroy_subscription(self._camera_subscriptions.pop(camera_id)) + with self._lock: + self._camera_frames.pop(camera_id, None) + + def _on_camera_image(self, msg: CompressedImage, camera_id='legacy', topic=None): with self._lock: + if camera_id != 'legacy' and camera_id not in self._camera_subscriptions: + return self._camera_image = msg self._camera_image_count += 1 self._last_camera_image_wall_time = time.time() + self._camera_frames[camera_id] = (msg, self._last_camera_image_wall_time, + topic or self.camera_image_topic) + + def camera_sources(self): + now = time.time() + with self._lock: + frames = list(self._camera_frames.items()) + return [{"id": key, "name": f'/dev/{key}' if key != 'legacy' else 'Camera', + "topic": topic, "available": now - received <= _CAMERA_FRAME_MAX_AGE_SECONDS + and bool(image.data) and 'jpeg' in image.format.lower()} + for key, (image, received, topic) in sorted(frames)] def _active_point_cloud(self) -> Optional[PointCloud2]: with self._lock: @@ -687,7 +726,6 @@ class SlamNode(Node): point_cloud_last = self._last_point_cloud_wall_time fallback_point_cloud_last = self._last_fallback_point_cloud_wall_time color_point_cloud_last = self._last_color_point_cloud_wall_time - camera_image_last = self._last_camera_image_wall_time map_count = self._map_count plan_count = self._plan_count @@ -727,7 +765,7 @@ class SlamNode(Node): }, "camera_image": { "name": self.camera_image_topic, - "available": received_recently(camera_image_last), + "available": any(camera['available'] for camera in self.camera_sources()), }, "plan": {"name": self.plan_topic, "available": plan_available}, "cmd_vel": {"name": self.cmd_vel_topic, "available": cmd_vel_available}, @@ -1238,6 +1276,7 @@ class SlamNode(Node): "sensor_point_cloud": sensor_point_cloud_payload, "color_point_cloud": color_point_cloud_payload, "camera_image": camera_image_payload, + "cameras": self.camera_sources(), "map": map_payload, "tf": { "map_to_odom": resolve_2d_transform(tf_snapshot, "map", "odom"), @@ -1302,10 +1341,16 @@ class SlamNode(Node): payload["received_at"] = received_at return payload - def camera_frame_jpeg(self) -> Optional[bytes]: + def camera_frame_jpeg(self, camera_id=None) -> Optional[bytes]: with self._lock: - image = self._camera_image - received_at = self._last_camera_image_wall_time + if camera_id is not None: + entry = self._camera_frames.get(camera_id) + if entry is None: + return None + image, received_at, _topic = entry + else: + image = self._camera_image + received_at = self._last_camera_image_wall_time if image is None or received_at is None or time.time() - received_at > _CAMERA_FRAME_MAX_AGE_SECONDS: return None if not image.data or "jpeg" not in image.format.lower(): @@ -1531,10 +1576,10 @@ def color_point_cloud(): @app.get("/camera-frame") -def camera_frame(): +def camera_frame(camera_id: Optional[str] = None): if not slam_node: raise HTTPException(status_code=503, detail="ROS node is not ready") - frame = slam_node.camera_frame_jpeg() + frame = slam_node.camera_frame_jpeg(camera_id) if frame is None: raise HTTPException(status_code=404, detail="카메라 프레임을 아직 수신하지 못했습니다.") return Response(content=frame, media_type="image/jpeg", headers={"Cache-Control": "no-store"}) diff --git a/resources/slam-ros-bridge/test_cameras.py b/resources/slam-ros-bridge/test_cameras.py new file mode 100644 index 0000000..2f5ab78 --- /dev/null +++ b/resources/slam-ros-bridge/test_cameras.py @@ -0,0 +1,67 @@ +import ast +from pathlib import Path +import re +import threading +from types import SimpleNamespace +from typing import Optional +import unittest + + +class CameraTests(unittest.TestCase): + def setUp(self): + self.now = 10.0 + source = ast.parse(Path(__file__).with_name('bridge.py').read_text()) + node = next(item for item in source.body if isinstance(item, ast.ClassDef) and item.name == 'SlamNode') + names = ('_discover_cameras', '_on_camera_image', 'camera_sources', 'camera_frame_jpeg') + methods = [item for item in node.body if isinstance(item, ast.FunctionDef) and item.name in names] + scope = {'re': re, 'Optional': Optional, 'CompressedImage': object, + 'time': SimpleNamespace(time=lambda: self.now), '_CAMERA_FRAME_MAX_AGE_SECONDS': 3, + 'qos_profile_sensor_data': object()} + exec(compile(ast.Module(body=methods, type_ignores=[]), 'camera-bridge', 'exec'), scope) + self.node = type('CameraNode', (), {name: scope[name] for name in names})() + self.node._lock = threading.Lock() + self.node._camera_frames = {} + self.node._camera_subscriptions = {} + self.node._camera_image = None + self.node._last_camera_image_wall_time = None + self.node._camera_image_count = 0 + self.node.camera_image_topic = '/camera_test/image_jpeg' + self.topics = [] + self.callbacks = {} + self.node.get_topic_names_and_types = lambda: self.topics + def subscribe(kind, topic, callback, qos): + self.callbacks[topic] = callback + return topic + self.node.create_subscription = subscribe + self.node.destroy_subscription = lambda sub: self.callbacks.pop(sub) + + def test_independent_frames_and_disconnect(self): + for key in ['video0', 'video2']: + self.topics.append((f'/camera_test/cameras/{key}/image_jpeg', ['sensor_msgs/msg/CompressedImage'])) + self.topics += [('/other/image_jpeg', ['sensor_msgs/msg/CompressedImage']), + ('/camera_test/cameras/video9/image_jpeg', ['sensor_msgs/msg/Image'])] + self.node._discover_cameras() + self.assertEqual(len(self.callbacks), 2) + for topic, callback in self.callbacks.items(): + callback(SimpleNamespace(data=topic.encode(), format='jpeg')) + self.assertEqual([item['id'] for item in self.node.camera_sources()], ['video0', 'video2']) + self.assertIn(b'video2', self.node.camera_frame_jpeg('video2')) + self.assertIsNone(self.node.camera_frame_jpeg('../video0')) + self.now += 4 + self.assertTrue(all(not item['available'] for item in self.node.camera_sources())) + self.assertIsNone(self.node.camera_frame_jpeg('video0')) + self.topics = self.topics[:1] + self.node._discover_cameras() + self.assertNotIn('video2', self.node._camera_frames) + + def test_single_camera_compatibility_and_non_jpeg(self): + self.node._on_camera_image(SimpleNamespace(data=b'jpeg', format='jpeg')) + self.assertEqual(self.node.camera_frame_jpeg(), b'jpeg') + self.assertEqual(self.node.camera_frame_jpeg('legacy'), b'jpeg') + self.node._on_camera_image(SimpleNamespace(data=b'png', format='png')) + self.assertIsNone(self.node.camera_frame_jpeg('legacy')) + self.assertFalse(self.node.camera_sources()[0]['available']) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/test-agent-code-navigation.cjs b/scripts/test-agent-code-navigation.cjs index 67146c8..2b8d5d4 100644 --- a/scripts/test-agent-code-navigation.cjs +++ b/scripts/test-agent-code-navigation.cjs @@ -98,6 +98,51 @@ test('legacy cancelled references can be opened with the same read-only navigati assert.equal(h.message().codeReferenceStates.location.status, 'opened') }) +test('code navigation does not block or abort a pending model response', async () => { + const h = consentHarness() + const controller = new AbortController() + h.globals.abortControllerRef.current = controller + let opened = 0 + await h.openCodeReference('answer', 'location', async (_reference, isCurrent) => { + assert.equal(isCurrent(), true) + opened++ + }) + assert.equal(opened, 1) + assert.equal(controller.signal.aborted, false) + assert.equal(h.globals.abortControllerRef.current, controller) + assert.equal(h.message().codeReferenceStates.location.status, 'opened') +}) + +test('an arriving model response does not cancel an in-flight code navigation', async () => { + const h = consentHarness() + h.globals.abortControllerRef.current = new AbortController() + let finish, current + const pending = h.openCodeReference('answer', 'location', (_reference, isCurrent) => { + current = isCurrent + return new Promise(resolve => { finish = resolve }) + }) + h.globals.messageStateRef.current.messages.push({ id: 'new-answer', role: 'assistant' }) + h.globals.abortControllerRef.current = null + assert.equal(current(), true) + finish() + await pending + assert.equal(h.globals.messageStateRef.current.messages[0].codeReferenceStates.location.status, 'opened') +}) + +test('starting file application invalidates a navigation that is still reading', async () => { + const h = consentHarness() + let finish, current + const pending = h.openCodeReference('answer', 'location', (_reference, isCurrent) => { + current = isCurrent + return new Promise((_resolve, reject) => { finish = () => reject(Error('applying')) }) + }) + h.globals.applyingRef.current = true + assert.equal(current(), false) + finish() + await pending + assert.equal(h.message().codeReferenceStates.location.status, 'error') +}) + test('duplicate clicks, invalid IDs and busy tasks do not open more files', async () => { const h = consentHarness() let finish, opened = 0 diff --git a/scripts/test-ai-response-presentation.cjs b/scripts/test-ai-response-presentation.cjs index 52b0027..6f48c35 100644 --- a/scripts/test-ai-response-presentation.cjs +++ b/scripts/test-ai-response-presentation.cjs @@ -22,6 +22,7 @@ const panelSource = ts.createSourceFile('WorkspaceQaPanel.tsx', fs.readFileSync( const declarations = panelSource.statements.filter(node => ts.isFunctionDeclaration(node) && ['AiMessages', 'AgentMessages', 'TeacherMessages'].includes(node.name?.text)) const ProgressiveChatResponse = props => React.createElement('div', { 'data-reveal-start': props.message.revealStartedAt, 'data-collapsed': props.defaultCollapsed }, props.message.content) const ChatImageAttachments = props => React.createElement('div', { 'data-chat-image-attachments': props.variant }, props.images.map(image => React.createElement('img', { key: image.url, src: image.url, alt: image.name }))) +const AgentCodeReferences = () => null const { AgentWorkDuration } = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/AgentWorkDuration.tsx'), 'utf8'), { '../i18n': { t: (key, values = {}) => key.replace(/\{\{(\w+)\}\}/g, (_, name) => values[name] ?? ''), useLocaleRender() {} } }) @@ -36,6 +37,7 @@ const messages = compile(declarations.map(node => `export ${node.getText(panelSo ChatEmptyState: widgets.ChatEmptyState, ProgressiveChatResponse, ChatImageAttachments, + AgentCodeReferences, AgentProgressBubble, AgentWorkDuration, MessageCopyButton: () => null, @@ -43,6 +45,27 @@ const messages = compile(declarations.map(node => `export ${node.getText(panelSo Bot: () => React.createElement('svg'), WandSparkles: () => React.createElement('svg') }) +test('existing code locations stay usable during model and command work but not file application', () => { + for (const applying of [false, true]) { + const onOpen = () => {} + const tree = messages.AgentMessages({ + dark: true, enabled: true, statusLoading: false, pending: true, busy: true, + messages: [{ id: 'answer', role: 'assistant', content: '코드 위치', applyState: applying ? 'applying' : undefined, plan: { codeReferences: [{ id: 'code-1' }], changes: [], commands: [], actionPlan: [], createModule: null } }], + onOpenCodeReference: onOpen + }) + let references + function visit(element) { + if (!React.isValidElement(element)) return + if (element.type === AgentCodeReferences) references = element + React.Children.toArray(element.props.children).forEach(visit) + } + visit(tree) + assert.ok(references) + assert.equal(references.props.busy, applying) + assert.equal(typeof references.props.onOpen, 'function') + } +}) + test('agent image thumbnails are siblings below the user bubble rather than bubble contents', () => { const tree = messages.AgentMessages({ dark: true, enabled: true, statusLoading: false, pending: false, diff --git a/src/main/index.ts b/src/main/index.ts index bfd78cf..0770d59 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2563,6 +2563,23 @@ async function startRemoteVisualizationBridge( ].join('\n'), 900_000) } const command = bridgeContainerCommand(stack) + let bridgeContextRevision: string | null = null + if (stack !== 'autoware') { + const context = resolveResourceContextPath('resources/slam-ros-bridge') + if (!context) throw new Error('Bundled SLAM bridge source is missing') + const files = { 'bridge.py': await readFile(join(context, 'bridge.py'), 'utf8'), 'requirements.txt': await readFile(join(context, 'requirements.txt'), 'utf8') } + bridgeContextRevision = createHash('sha256').update(JSON.stringify(files)).digest('hex') + await runUnqueuedSshOutput(target, `python3 -c ${quoteShellSingle([ + 'import json, os, pathlib, sys', + 'data = json.load(sys.stdin)', + 'root = pathlib.Path.home() / ".autonomy-studio/runtime/bridges" / data["revision"]', + 'root.mkdir(parents=True, exist_ok=True)', + 'for name in ("bridge.py", "requirements.txt"):', + ' temporary = root / ("." + name + "." + str(os.getpid()) + ".tmp")', + ' temporary.write_text(data["files"][name], encoding="utf-8")', + ' temporary.replace(root / name)', + ].join('\n'))}`, 30_000, JSON.stringify({ revision: bridgeContextRevision, files })) + } reportVisualizationProgress('시각화 시작 중...') @@ -2580,6 +2597,7 @@ async function startRemoteVisualizationBridge( 'docker run -d --name "$CONTAINER" --net=host', '-v /dev/shm:/dev/shm', '-v "$ROOT:/workspace"', + ...(bridgeContextRevision ? [`-v "$HOME/.autonomy-studio/runtime/bridges/${bridgeContextRevision}:/app:ro"`] : []), ...(fastDdsConfigPath ? ['-v "$FASTDDS_CONFIG_PATH:/fastdds_profiles.xml:ro"', '-e FASTDDS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml', '-e FASTRTPS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml'] : []), '-e "ROS_DOMAIN_ID=$ROS_DOMAIN_ID"', '-e ROS_LOCALHOST_ONLY=0', @@ -4777,10 +4795,19 @@ function buildNav2DockerRunScript(): string { 'test -f "$MODULE_ROOT/$MODULE_FOLDER/install/setup.bash" || { echo "Module is not built: $MODULE_FOLDER" >&2; exit 1; }', ...yahboomDevicePreflight(), 'CAMERA_DEVICE=""', + 'CAMERA_DEVICES=()', 'if [[ "$MODULE_FOLDER" == *lms-nav2-camera-test* ]]; then', ' CAMERA_DEVICE=/dev/video0', ' for arg in "$@"; do case "$arg" in video_device:=*) CAMERA_DEVICE=${arg#video_device:=} ;; esac; done', - ' [[ "$CAMERA_DEVICE" =~ ^/dev/video[0-9]+$ ]] && [ -c "$CAMERA_DEVICE" ] || { echo "Camera device not found: $CAMERA_DEVICE" >&2; exit 1; }', + ' if [ "$CAMERA_DEVICE" = /dev ]; then', + ' CAMERA_DISCOVERY="$MODULE_ROOT/$MODULE_FOLDER/src/camera_test_pkg/camera_test_pkg/devices.py"', + ' test -f "$CAMERA_DISCOVERY" || { echo "Update and rebuild the camera module for automatic discovery" >&2; exit 1; }', + ' camera_devices=$(python3 "$CAMERA_DISCOVERY" /dev) || exit 1', + ' mapfile -t CAMERA_DEVICES <<< "$camera_devices"', + ' else CAMERA_DEVICES=("$CAMERA_DEVICE"); fi', + ' for camera_device in "${CAMERA_DEVICES[@]}"; do', + ' [[ "$camera_device" =~ ^/dev/video[0-9]+$ ]] && [ -c "$camera_device" ] || { echo "Camera device not found: $camera_device" >&2; exit 1; }', + ' done', 'fi', 'docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true', '', @@ -4800,7 +4827,7 @@ function buildNav2DockerRunScript(): string { ')', 'if [ -n "$YAHBOOM_DEVICE" ]; then args+=(--device "$YAHBOOM_DEVICE:$YAHBOOM_DEVICE"); fi', 'if [ -n "$CAMERA_DEVICE" ]; then', - ' args+=(--device "$CAMERA_DEVICE:$CAMERA_DEVICE")', + ' for camera_device in "${CAMERA_DEVICES[@]}"; do args+=(--device "$camera_device:$camera_device"); done', ' for capture_device in /dev/capture-vi-channel*; do', ' if [ -c "$capture_device" ]; then args+=(--device "$capture_device:$capture_device"); fi', ' done', diff --git a/src/renderer/public/slam-viewer/camera-preview.js b/src/renderer/public/slam-viewer/camera-preview.js new file mode 100644 index 0000000..c7bcc54 --- /dev/null +++ b/src/renderer/public/slam-viewer/camera-preview.js @@ -0,0 +1,113 @@ +export function createCameraPreview({ root, baseUrl, getState, onAvailable }) { + const cameras = new Map(); + let enabled = false; + let timer = null; + + function sources() { + const { state, receivedAt } = getState(); + if (!enabled || Date.now() - receivedAt >= 3000) return []; + const list = Array.isArray(state?.cameras) ? state.cameras + : state?.topic_status?.camera_image?.available ? [{ id: 'legacy', name: 'Camera', available: true }] : []; + const seen = new Set(); + return list.filter(item => { + if (!item?.available || !/^[a-zA-Z0-9_-]{1,64}$/.test(item.id) || seen.has(item.id)) return false; + seen.add(item.id); + return true; + }).slice(0, 8); + } + + function clearFrame(camera) { + if (camera.url) URL.revokeObjectURL(camera.url); + camera.url = null; + camera.image.removeAttribute('src'); + } + + function remove(id, camera) { + cameras.delete(id); + camera.controller?.abort(); + clearFrame(camera); + camera.element.remove(); + } + + async function frame(id, camera) { + if (camera.controller || Date.now() < camera.retryAt || document.hidden) return; + const controller = new AbortController(); + camera.controller = controller; + const timeout = window.setTimeout(() => controller.abort(), 2000); + try { + const query = id === 'legacy' ? '' : `?camera_id=${encodeURIComponent(id)}`; + const response = await fetch(`${baseUrl}/camera-frame${query}`, { cache: 'no-store', signal: controller.signal }); + if (!response.ok) throw new Error('Camera frame unavailable'); + const blob = await response.blob(); + if (controller.signal.aborted || document.hidden || cameras.get(id) !== camera || !sources().some(item => item.id === id)) return; + const previous = camera.url; + camera.url = URL.createObjectURL(blob); + camera.image.src = camera.url; + camera.retryAt = 0; + if (previous) URL.revokeObjectURL(previous); + } catch (_) { + if (cameras.get(id) === camera) { + camera.retryAt = Date.now() + 5000; + clearFrame(camera); + } + } finally { + window.clearTimeout(timeout); + if (camera.controller === controller) camera.controller = null; + } + } + + function refresh() { + const live = sources(); + const ids = new Set(live.map(item => item.id)); + for (const [id, camera] of cameras) if (!ids.has(id)) remove(id, camera); + for (const item of live) { + let camera = cameras.get(item.id); + if (!camera) { + const element = document.createElement('div'); + element.className = 'camera-preview-item'; + element.dataset.cameraId = item.id; + const label = document.createElement('div'); + label.className = 'camera-preview-label'; + const image = document.createElement('img'); + image.className = 'camera-preview-image'; + element.append(label, image); + root.append(element); + camera = { element, label, image, url: null, controller: null, retryAt: 0 }; + image.addEventListener('error', () => { + if (cameras.get(item.id) === camera) { + clearFrame(camera); + camera.retryAt = Date.now() + 5000; + } + }); + cameras.set(item.id, camera); + } + camera.label.textContent = item.name || item.id; + camera.image.alt = item.name || item.id; + void frame(item.id, camera); + } + onAvailable(enabled && cameras.size > 0); + } + + function visibilityChanged() { + if (document.hidden) for (const camera of cameras.values()) camera.controller?.abort(); + else refresh(); + } + document.addEventListener('visibilitychange', visibilityChanged); + + function setEnabled(value) { + enabled = value; + if (timer) window.clearInterval(timer); + timer = null; + refresh(); + if (enabled) timer = window.setInterval(refresh, 200); + } + + return { + setEnabled, + refresh, + dispose() { + setEnabled(false); + document.removeEventListener('visibilitychange', visibilityChanged); + }, + }; +} diff --git a/src/renderer/public/slam-viewer/index.html b/src/renderer/public/slam-viewer/index.html index 65434d1..641a895 100644 --- a/src/renderer/public/slam-viewer/index.html +++ b/src/renderer/public/slam-viewer/index.html @@ -61,14 +61,29 @@ #cameraPreviewWrap.is-visible { display:block; } #cameraToggle { display:grid; place-items:center; width:34px; flex:0 0 34px; padding:0; } #cameraPreviewWrap.is-collapsed { background:transparent; border-color:transparent; box-shadow:none; } - #cameraPreview { + #cameraPreviewList { + max-height:min(60vh, 600px); + overflow-y:auto; + scrollbar-width:thin; + scrollbar-color:rgba(148,163,184,.65) transparent; + } + .camera-preview-item + .camera-preview-item { border-top:1px solid rgba(255,255,255,.18); } + .camera-preview-label { + padding:5px 8px; + overflow:hidden; + text-overflow:ellipsis; + white-space:nowrap; + color:rgba(255,255,255,.85); + font:11px sans-serif; + } + .camera-preview-image { display:block; width:100%; aspect-ratio:16 / 9; object-fit:contain; background:#111520; } - #cameraPreviewWrap.is-collapsed #cameraPreview { display:none; } + #cameraPreviewWrap.is-collapsed #cameraPreviewList { display:none; } #cameraPreviewToggle { position:absolute; left:0; @@ -426,7 +441,7 @@
- 실시간 카메라 화면 +
i 전진 · , 후진 · j/l 회전 · k 정지
@@ -463,6 +478,7 @@ ' })), { id: 'video0', available: true }] }) + h.controller.setEnabled(true); await h.settle() + assert.equal(h.requests.length, 8) + assert.equal(h.root.children.length, 8) + assert.equal(h.root.children[0].children[0].textContent, '') +})