From 99b354b4c0e5e876672de896f6260fa32235f643 Mon Sep 17 00:00:00 2001 From: joaner Date: Wed, 27 May 2026 17:13:55 +0800 Subject: [PATCH 1/2] feat: adopt @ioai/hdf5 1.0.0 and self-contained E2E fixtures Upgrade HDF5 loading to the published initHdf5({ wasmBinary }) API for inline workers, add checked-in test fixtures with generation scripts, and remove skipped/conditional E2E tests so CI runs a stable 347+29 suite. --- .github/workflows/ci.yml | 4 +- docs/DEVELOPMENT.md | 30 ++--- package-lock.json | 8 +- package.json | 4 +- scripts/gen-e2e-fixtures.mjs | 35 ++++++ scripts/gen-test-bvh.mjs | 12 ++ scripts/gen-test-hdf5.py | 56 +++++++++ scripts/gen-test-mcap-3cam.mjs | 42 +++++++ scripts/gen-test-mcap-h264.mjs | 43 +++++++ scripts/gen-test-mcap-pose.mjs | 55 ++++++++ scripts/mcap-fixture-utils.mjs | 132 ++++++++++++++++++++ src/core/preferences/foxgloveLayout.test.ts | 44 +++---- src/features/viewer/RosViewerImpl.tsx | 23 +++- src/infra/sources/hdf5/H5FileSystem.ts | 26 +++- src/infra/workers/hdf5.worker.ts | 19 ++- src/infra/workers/hdf5RuntimeLoader.ts | 20 +++ src/shared/utils/resolveWorkerHttpUrl.ts | 10 +- test-fixtures/layouts/agibot-like.json | 97 ++++++++++++++ test-fixtures/layouts/multi-canvas.json | 117 +++++++++++++++++ test-fixtures/media/h264-delta.bin | Bin 0 -> 10 bytes test-fixtures/media/h264-key.bin | Bin 0 -> 696 bytes test-fixtures/media/jpeg-1x1.bin | Bin 0 -> 338 bytes test-fixtures/media/minimal-aloha.h5 | Bin 0 -> 7440 bytes test-fixtures/media/minimal.bvh | 40 ++++++ tests/basic.spec.ts | 41 ++---- tests/bvh-basic.spec.ts | 15 +++ tests/delivery.spec.ts | 31 +++-- tests/dockview-chrome.spec.ts | 43 ++----- tests/fixturePaths.ts | 33 +++++ tests/hdf5-basic.spec.ts | 48 ++----- tests/image-h264.spec.ts | 25 ++-- tests/multi-sources.spec.ts | 20 +-- tests/pose-panel.spec.ts | 22 +--- tests/ros-image-grid.spec.ts | 19 +-- tests/transport-fallback.spec.ts | 45 +++---- vite.config.ts | 5 +- 36 files changed, 894 insertions(+), 270 deletions(-) create mode 100644 scripts/gen-e2e-fixtures.mjs create mode 100644 scripts/gen-test-bvh.mjs create mode 100644 scripts/gen-test-hdf5.py create mode 100644 scripts/gen-test-mcap-3cam.mjs create mode 100644 scripts/gen-test-mcap-h264.mjs create mode 100644 scripts/gen-test-mcap-pose.mjs create mode 100644 scripts/mcap-fixture-utils.mjs create mode 100644 src/infra/workers/hdf5RuntimeLoader.ts create mode 100644 test-fixtures/layouts/agibot-like.json create mode 100644 test-fixtures/layouts/multi-canvas.json create mode 100644 test-fixtures/media/h264-delta.bin create mode 100644 test-fixtures/media/h264-key.bin create mode 100644 test-fixtures/media/jpeg-1x1.bin create mode 100644 test-fixtures/media/minimal-aloha.h5 create mode 100644 test-fixtures/media/minimal.bvh create mode 100644 tests/bvh-basic.spec.ts create mode 100644 tests/fixturePaths.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d99c3ae..fea190c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,8 +30,8 @@ jobs: cache: 'npm' - run: npm ci - run: npx playwright install chromium --with-deps - - name: Generate E2E MCAP fixture - run: node scripts/gen-test-mcap.mjs + - name: Generate E2E fixtures + run: npm run gen:e2e:fixtures - run: npm run test:e2e env: CI: true diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 836332c..efce917 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -11,26 +11,26 @@ | `npm run build:lib` | `tsc` + **npm package** build (`vite.lib.config.ts` → `dist-lib/`); used by `prepublishOnly` and embedders. | | `npm run test:e2e` | Playwright (requires fixture MCAP; see below). | -## Fixtures (`public/examples/`) +## Fixtures -Place sample bags/MCAP files here for local dev and E2E. Expected names include: - -- `test_5s.mcap` — minimal indexed MCAP for most Playwright cases (generated automatically before `test:e2e`; see `scripts/gen-test-mcap.mjs`) -- `episode_20260122_122345.hdf5` — optional HDF5 case - -Regenerate the default small MCAP (also run as `pretest:e2e` via `npm run gen:e2e:fixtures`): +Committed sources live under **`test-fixtures/`** (layouts, minimal HDF5/BVH, H264/JPEG bytes). Playwright copies or generates runtime files into **`public/examples/`** (gitignored) via: ```bash npm run gen:e2e:fixtures ``` -Override paths when files live elsewhere: +This runs automatically as `pretest:e2e` before `npm run test:e2e`. -```bash -export ROSVIEW_TEST_MCAP=/absolute/path/to/test_5s.mcap -export ROSVIEW_TEST_HDF5=/absolute/path/to/episode.hdf5 -npm run test:e2e -``` +| Generated file (`public/examples/`) | Purpose | +|-------------------------------------|---------| +| `test_5s.mcap` | Basic MCAP playback, dockview, transport | +| `test_pose.mcap` | PoseStamped sidebar topics | +| `test_3cam.mcap` | Three-camera image grid layout | +| `test_h264.mcap` | H.264 CompressedImage decode | +| `test_minimal.hdf5` | ALOHA-schema HDF5 (~7 KB) | +| `test_minimal.bvh` | Minimal BVH skeleton | + +Vitest layout round-trip tests import JSON directly from `test-fixtures/layouts/`. For sample deep links (`?url=sample://…`), set `VITE_SAMPLE_DATASETS_MANIFEST_URL` in `.env` to a reachable JSON manifest (see `src/services/sampleDatasets.ts`). @@ -62,7 +62,7 @@ Prefer main-thread rendering and subscription tuning before MCAP-parse WASM. Con **Prerequisites** 1. `npm install` and (first time) `npx playwright install`. -2. Put `test_5s.mcap` under `public/examples/` or set `ROSVIEW_TEST_MCAP`. +2. `npm run gen:e2e:fixtures` (also runs automatically before `test:e2e`). 3. `npm run dev` → `http://localhost:5173`. **Playwright** @@ -80,4 +80,4 @@ npm run test:e2e 3. Switch to **Data** if multiple sources are present; the successfully loaded row is highlighted. 4. Open `/`, upload or drag a local `.mcap`; confirm load succeeds. -If no local sample is available, smoke-test routing and sidebar shell only; full Range behavior needs same-origin static assets and correct CORS/Range headers. +Full E2E coverage requires `npm run gen:e2e:fixtures` so `public/examples/` is populated; no files outside the repo are needed. diff --git a/package-lock.json b/package-lock.json index 20c4ddc..510ef48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "@foxglove/rosmsg": "^5.0.5", "@foxglove/rosmsg-serialization": "^2.0.4", "@foxglove/rosmsg2-serialization": "^3.0.3", - "@ioai/hdf5": "^0.1.4", + "@ioai/hdf5": "^1.0.0", "@mcap/core": "^2.0.2", "@playwright/test": "^1.59.1", "@radix-ui/react-collapsible": "^1.1.12", @@ -1310,9 +1310,9 @@ } }, "node_modules/@ioai/hdf5": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@ioai/hdf5/-/hdf5-0.1.5.tgz", - "integrity": "sha512-InS+C/o/QfZmol0Z1Z9HW9OQvew6M7adaAXKjGQFZ+p0fjjd92FIVgg5GLp6q5OliSBSS01xwbUx0TMslltSEg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@ioai/hdf5/-/hdf5-1.0.0.tgz", + "integrity": "sha512-eOLyKhkoT6KGqRNPYSkMX6UKg2rRlQXkFWN7aUrnXrizpYl733n1Le61Dyp9KzvU8f1cRqAHVjMEvEiDgfl7jw==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 7b731b4..3df8389 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "lint": "eslint \"src/**/*.{ts,tsx}\" \"tests/**/*.ts\"", "test": "vitest run", "preview": "npm run build && vite preview", - "gen:e2e:fixtures": "node scripts/gen-test-mcap.mjs", + "gen:e2e:fixtures": "node scripts/gen-e2e-fixtures.mjs", "pretest:e2e": "npm run gen:e2e:fixtures", "test:e2e": "playwright test" }, @@ -96,7 +96,7 @@ "@foxglove/rosmsg": "^5.0.5", "@foxglove/rosmsg-serialization": "^2.0.4", "@foxglove/rosmsg2-serialization": "^3.0.3", - "@ioai/hdf5": "^0.1.4", + "@ioai/hdf5": "^1.0.0", "@mcap/core": "^2.0.2", "@playwright/test": "^1.59.1", "@radix-ui/react-collapsible": "^1.1.12", diff --git a/scripts/gen-e2e-fixtures.mjs b/scripts/gen-e2e-fixtures.mjs new file mode 100644 index 0000000..309ecde --- /dev/null +++ b/scripts/gen-e2e-fixtures.mjs @@ -0,0 +1,35 @@ +/** + * Generate all Playwright E2E fixtures into public/examples/. + */ +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +/** @param {string} script */ +function runNode(script) { + const scriptPath = path.join(__dirname, script); + const result = spawnSync(process.execPath, [scriptPath], { stdio: 'inherit' }); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +/** @param {string} script */ +function runPython(script) { + const scriptPath = path.join(__dirname, script); + const result = spawnSync('python3', [scriptPath], { stdio: 'inherit' }); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +runNode('gen-test-mcap.mjs'); +runNode('gen-test-mcap-pose.mjs'); +runNode('gen-test-mcap-3cam.mjs'); +runNode('gen-test-mcap-h264.mjs'); +runPython('gen-test-hdf5.py'); +runNode('gen-test-bvh.mjs'); + +console.log('[gen-e2e-fixtures] all fixtures ready'); diff --git a/scripts/gen-test-bvh.mjs b/scripts/gen-test-bvh.mjs new file mode 100644 index 0000000..5c497b9 --- /dev/null +++ b/scripts/gen-test-bvh.mjs @@ -0,0 +1,12 @@ +/** + * Copy minimal BVH fixture into public/examples/ for E2E. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { FIXTURES_DIR, EXAMPLES_DIR } from './mcap-fixture-utils.mjs'; + +const src = path.join(FIXTURES_DIR, 'media/minimal.bvh'); +const dest = path.join(EXAMPLES_DIR, 'test_minimal.bvh'); +fs.mkdirSync(EXAMPLES_DIR, { recursive: true }); +fs.copyFileSync(src, dest); +console.log('Wrote', dest, `(${fs.statSync(dest).size} bytes)`); diff --git a/scripts/gen-test-hdf5.py b/scripts/gen-test-hdf5.py new file mode 100644 index 0000000..54f8d4f --- /dev/null +++ b/scripts/gen-test-hdf5.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Generate or copy minimal ALOHA-schema HDF5 fixture into public/examples/.""" +from __future__ import annotations + +import shutil +import sys +from pathlib import Path + +try: + import h5py + import numpy as np +except ImportError: + h5py = None # type: ignore + np = None # type: ignore + +ROOT = Path(__file__).resolve().parent.parent +FIXTURE_SRC = ROOT / 'test-fixtures' / 'media' / 'minimal-aloha.h5' +OUT = ROOT / 'public' / 'examples' / 'test_minimal.hdf5' + + +def generate() -> None: + assert h5py is not None and np is not None + n, k, h, w, c = 4, 3, 2, 2, 3 + FIXTURE_SRC.parent.mkdir(parents=True, exist_ok=True) + with h5py.File(FIXTURE_SRC, 'w') as h5: + h5.create_dataset('/action', data=np.arange(n * k, dtype=np.float32).reshape(n, k)) + h5.create_dataset('/observations/qpos', data=(np.arange(n * k, dtype=np.float32).reshape(n, k) * 2)) + h5.create_dataset('/observations/qvel', data=np.zeros((n, k), dtype=np.float32)) + h5.create_dataset('/observations/tau_J', data=np.zeros((n, k), dtype=np.float32)) + h5.create_dataset('/observations/ee_pos_t', data=np.zeros((n, 3), dtype=np.float32)) + h5.create_dataset( + '/observations/ee_pos_q', + data=np.tile([0, 0, 0, 1], (n, 1)).astype(np.float32), + ) + h5.create_dataset( + '/observations/images/ext1', + data=np.arange(n * h * w * c, dtype=np.uint8).reshape(n, h, w, c), + ) + h5.create_dataset('/tm', data=np.full((n, 1), 0.125, dtype=np.float32)) + print(f'Generated {FIXTURE_SRC} ({FIXTURE_SRC.stat().st_size} bytes)') + + +def main() -> int: + if not FIXTURE_SRC.exists(): + if h5py is None: + print('h5py not installed and committed fixture missing', file=sys.stderr) + return 1 + generate() + OUT.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(FIXTURE_SRC, OUT) + print(f'Wrote {OUT} ({OUT.stat().st_size} bytes)') + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/scripts/gen-test-mcap-3cam.mjs b/scripts/gen-test-mcap-3cam.mjs new file mode 100644 index 0000000..bfdec88 --- /dev/null +++ b/scripts/gen-test-mcap-3cam.mjs @@ -0,0 +1,42 @@ +/** + * Minimal MCAP with three compressed JPEG camera topics for ros-image-grid E2E. + */ +import { + createIndexedMcapWriter, + encodeCompressedImageCdr, + readFixture, + registerCompressedImageChannel, + writeExample, +} from './mcap-fixture-utils.mjs'; + +const jpegBytes = readFixture('media/jpeg-1x1.bin'); + +const { writer, writable } = await createIndexedMcapWriter(); + +const topics = [ + '/camera/left/color/image_raw/compressed', + '/camera/top/color/image_raw/compressed', + '/camera/right/color/image_raw/compressed', +]; + +const channelIds = []; +for (const topic of topics) { + channelIds.push(await registerCompressedImageChannel(topic, writer)); +} + +const messageTimes = [1_000_000_000n, 3_000_000_000n, 5_000_000_000n]; +for (const [idx, ts] of messageTimes.entries()) { + const stamp = { sec: Number(ts / 1_000_000_000n), nsec: Number(ts % 1_000_000_000n) }; + for (const channelId of channelIds) { + await writer.addMessage({ + channelId, + sequence: idx + 1, + logTime: ts, + publishTime: ts, + data: encodeCompressedImageCdr(stamp, 'jpeg', jpegBytes), + }); + } +} + +await writer.end(); +writeExample('test_3cam.mcap', writable.getBuffer()); diff --git a/scripts/gen-test-mcap-h264.mjs b/scripts/gen-test-mcap-h264.mjs new file mode 100644 index 0000000..b927a80 --- /dev/null +++ b/scripts/gen-test-mcap-h264.mjs @@ -0,0 +1,43 @@ +/** + * Minimal MCAP with H.264 CompressedImage messages for image-h264 E2E. + */ +import { + createIndexedMcapWriter, + encodeCompressedImageCdr, + readFixture, + registerCompressedImageChannel, + writeExample, +} from './mcap-fixture-utils.mjs'; + +const keyBytes = readFixture('media/h264-key.bin'); +const deltaBytes = readFixture('media/h264-delta.bin'); + +const { writer, writable } = await createIndexedMcapWriter(); + +const channelId = await registerCompressedImageChannel( + '/camera/head/color/image_raw/compressed', + writer, +); + +const frames = [ + { ts: 1_000_000_000n, data: keyBytes }, + { ts: 1_100_000_000n, data: deltaBytes }, + { ts: 1_200_000_000n, data: deltaBytes }, + { ts: 3_000_000_000n, data: keyBytes }, + { ts: 3_100_000_000n, data: deltaBytes }, + { ts: 5_000_000_000n, data: keyBytes }, +]; + +for (const [idx, { ts, data }] of frames.entries()) { + const stamp = { sec: Number(ts / 1_000_000_000n), nsec: Number(ts % 1_000_000_000n) }; + await writer.addMessage({ + channelId, + sequence: idx + 1, + logTime: ts, + publishTime: ts, + data: encodeCompressedImageCdr(stamp, 'h264', data), + }); +} + +await writer.end(); +writeExample('test_h264.mcap', writable.getBuffer()); diff --git a/scripts/gen-test-mcap-pose.mjs b/scripts/gen-test-mcap-pose.mjs new file mode 100644 index 0000000..5df7b96 --- /dev/null +++ b/scripts/gen-test-mcap-pose.mjs @@ -0,0 +1,55 @@ +/** + * Minimal MCAP with PoseStamped topics for pose-panel E2E. + */ +import { + createIndexedMcapWriter, + writeExample, +} from './mcap-fixture-utils.mjs'; + +const { writer, writable } = await createIndexedMcapWriter(); + +const schemaId = await writer.registerSchema({ + name: 'geometry_msgs/msg/PoseStamped', + encoding: 'jsonschema', + data: new TextEncoder().encode('{"type":"object"}'), +}); + +const topics = ['/io/pose/Left_Gripper', '/io/pose/Right_Gripper']; +const channelIds = []; +for (const topic of topics) { + channelIds.push( + await writer.registerChannel({ + schemaId, + topic, + messageEncoding: 'json', + metadata: new Map(), + }), + ); +} + +const messageTimes = [1_000_000_000n, 3_000_000_000n, 5_000_000_000n]; +for (const [idx, ts] of messageTimes.entries()) { + for (const [topicIdx, channelId] of channelIds.entries()) { + await writer.addMessage({ + channelId, + sequence: idx + 1, + logTime: ts, + publishTime: ts, + data: new TextEncoder().encode( + JSON.stringify({ + header: { + stamp: { sec: Number(ts / 1_000_000_000n), nanosec: Number(ts % 1_000_000_000n) }, + frame_id: 'world', + }, + pose: { + position: { x: topicIdx + idx * 0.1, y: 0, z: 0 }, + orientation: { x: 0, y: 0, z: 0, w: 1 }, + }, + }), + ), + }); + } +} + +await writer.end(); +writeExample('test_pose.mcap', writable.getBuffer()); diff --git a/scripts/mcap-fixture-utils.mjs b/scripts/mcap-fixture-utils.mjs new file mode 100644 index 0000000..095a7ec --- /dev/null +++ b/scripts/mcap-fixture-utils.mjs @@ -0,0 +1,132 @@ +import { McapWriter } from '@mcap/core'; +import { MessageWriter } from '@foxglove/rosmsg2-serialization'; +import { parse as parseMessageDefinition } from '@foxglove/rosmsg'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const __mcapUtilsDir = path.dirname(fileURLToPath(import.meta.url)); +export const REPO_ROOT = path.join(__mcapUtilsDir, '..'); +export const FIXTURES_DIR = path.join(REPO_ROOT, 'test-fixtures'); +export const EXAMPLES_DIR = path.join(REPO_ROOT, 'public/examples'); + +export class BufferWritable { + /** @type {Buffer[]} */ + #chunks = []; + /** @type {bigint} */ + #pos = 0n; + + position() { + return this.#pos; + } + + /** @param {Uint8Array} buffer */ + write(buffer) { + const b = Buffer.from(buffer); + this.#chunks.push(b); + this.#pos += BigInt(b.byteLength); + return Promise.resolve(); + } + + getBuffer() { + return Buffer.concat(this.#chunks); + } +} + +/** @returns {Promise<{ writer: McapWriter, writable: BufferWritable }>} */ +export async function createIndexedMcapWriter() { + const writable = new BufferWritable(); + const writer = new McapWriter({ + writable, + useStatistics: true, + useChunks: true, + useChunkIndex: true, + }); + await writer.start({ profile: 'ros2', library: 'rosview-gen' }); + return { writer, writable }; +} + +/** + * @param {string} filename + * @param {Buffer} buffer + */ +export function writeExample(filename, buffer) { + fs.mkdirSync(EXAMPLES_DIR, { recursive: true }); + const outPath = path.join(EXAMPLES_DIR, filename); + fs.writeFileSync(outPath, buffer); + console.log('Wrote', outPath, `(${buffer.length} bytes)`); +} + +/** + * @param {string} relPath under test-fixtures/ + * @returns {Buffer} + */ +export function readFixture(relPath) { + return fs.readFileSync(path.join(FIXTURES_DIR, relPath)); +} + +const COMPRESSED_IMAGE_SCHEMA = `# sensor_msgs/msg/CompressedImage +std_msgs/Header header +string format +uint8[] data + +================================================================================ +MSG: std_msgs/Header +builtin_interfaces/Time stamp +string frame_id + +================================================================================ +MSG: builtin_interfaces/Time +int32 sec +uint32 nanosec +`; + +/** @type {ReturnType | undefined} */ +let compressedImageDefs; +/** @type {MessageWriter | undefined} */ +let compressedImageWriter; + +function getCompressedImageWriter() { + if (!compressedImageWriter) { + compressedImageDefs = parseMessageDefinition(COMPRESSED_IMAGE_SCHEMA, { ros2: true }); + compressedImageWriter = new MessageWriter(compressedImageDefs); + } + return compressedImageWriter; +} + +export function getCompressedImageSchemaBytes() { + return new TextEncoder().encode(COMPRESSED_IMAGE_SCHEMA); +} + +/** + * @param {{ sec: number, nsec: number }} stamp + * @param {string} format + * @param {Buffer | Uint8Array} data + */ +export function encodeCompressedImageCdr(stamp, format, data) { + const writer = getCompressedImageWriter(); + return writer.writeMessage({ + header: { stamp, frame_id: 'camera' }, + format, + data, + }); +} + +/** + * @param {string} topic + * @param {Awaited>['writer']} writer + */ +export async function registerCompressedImageChannel(topic, writer) { + const schemaId = await writer.registerSchema({ + name: 'sensor_msgs/msg/CompressedImage', + encoding: 'ros2msg', + data: getCompressedImageSchemaBytes(), + }); + const channelId = await writer.registerChannel({ + schemaId, + topic, + messageEncoding: 'cdr', + metadata: new Map(), + }); + return channelId; +} diff --git a/src/core/preferences/foxgloveLayout.test.ts b/src/core/preferences/foxgloveLayout.test.ts index 02f6c1d..20bd2d7 100644 --- a/src/core/preferences/foxgloveLayout.test.ts +++ b/src/core/preferences/foxgloveLayout.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { readFileSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; +import agibotLikeRaw from '../../../test-fixtures/layouts/agibot-like.json'; +import multiCanvasRaw from '../../../test-fixtures/layouts/multi-canvas.json'; import { buildFoxgloveLayout, collectMosaicPanelIds, @@ -14,25 +13,25 @@ import { } from './foxgloveLayout'; /** - * These tests load the real Foxglove layout samples the user attached. Each - * sample is walked through import -> export and we verify that: + * Committed Foxglove layout fixtures (test-fixtures/layouts/). Each sample is + * walked through import -> export and we verify that: * - Panel ids and topology survive the round-trip. * - Unknown panel config fields (e.g. 3D.cameraState) are preserved verbatim * in the re-exported `configById` via the per-panel extras. * - Canvas ids round-trip back to Canvas type (not Image). */ -function readSampleIfPresent(filename: string): FoxgloveLayoutData | null { - const path = join(homedir(), 'Downloads', filename); - try { - const raw = readFileSync(path, 'utf8'); - const parsed = parseFoxgloveLayout(JSON.parse(raw)); - return parsed; - } catch { - return null; +function parseFixture(raw: unknown): FoxgloveLayoutData { + const parsed = parseFoxgloveLayout(raw); + if (parsed == null) { + throw new Error('Invalid layout fixture'); } + return parsed; } +const sampleAgibot = parseFixture(agibotLikeRaw); +const sampleStudio = parseFixture(multiCanvasRaw); + function getAllMosaicLeaves(node: FoxgloveMosaicNode): string[] { return collectMosaicPanelIds(node); } @@ -324,14 +323,9 @@ describe('3D extras round-trip (preserve unknown fields)', () => { }); }); -// ---------- Real sample files (skipped when not present in ~/Downloads) ---------- - -const sampleAgibot = readSampleIfPresent('studio-layout-agibot.json'); -const sampleStudio = readSampleIfPresent('studio_layout__.json'); - -describe('real Foxglove samples round-trip', () => { - it.skipIf(sampleAgibot == null)('studio-layout-agibot.json import preserves all panels', () => { - const layout = sampleAgibot!; +describe('committed Foxglove layout fixtures round-trip', () => { + it('agibot-like layout import preserves all panels', () => { + const layout = sampleAgibot; const ids = collectMosaicPanelIds(layout.layout); const imported = importFoxgloveLayout(layout); expect(imported.restored).toBe(ids.length); @@ -341,8 +335,8 @@ describe('real Foxglove samples round-trip', () => { expect(imported.dockviewState).toBeDefined(); }); - it.skipIf(sampleAgibot == null)('studio-layout-agibot.json export keeps Canvas.topicPath and 3D extras', () => { - const layout = sampleAgibot!; + it('agibot-like layout export keeps Canvas.topicPath and 3D extras', () => { + const layout = sampleAgibot; const imported = importFoxgloveLayout(layout); // Build a synthetic apiState where every panel lives in its own leaf // (the real runtime goes through DockView first, but for export logic @@ -372,8 +366,8 @@ describe('real Foxglove samples round-trip', () => { expect(exported.globalVariables).toEqual(layout.globalVariables); }); - it.skipIf(sampleStudio == null)('studio_layout__.json import+export round-trip keeps configById keys', () => { - const layout = sampleStudio!; + it('multi-canvas layout import+export round-trip keeps configById keys', () => { + const layout = sampleStudio; const imported = importFoxgloveLayout(layout); const apiState = buildApiStateFromMosaic(layout.layout!, imported.panelStates); const exported = buildFoxgloveLayout({ diff --git a/src/features/viewer/RosViewerImpl.tsx b/src/features/viewer/RosViewerImpl.tsx index 41b20e5..e07ae5c 100644 --- a/src/features/viewer/RosViewerImpl.tsx +++ b/src/features/viewer/RosViewerImpl.tsx @@ -66,8 +66,10 @@ import { RosViewerLayoutProvider } from './RosViewerLayoutContext'; import type { RosViewExtension } from '@/core/extensions/types'; import { toast } from 'sonner'; import sqlWasmUrl from 'sql.js/dist/sql-wasm.wasm?url'; +import hdf5WasmUrl from '@ioai/hdf5/wasm/ioai_hdf5.wasm?url'; let sqlWasmBinaryPromise: Promise | null = null; +let hdf5WasmBinaryPromise: Promise | null = null; async function loadSqlWasmBinary(): Promise { sqlWasmBinaryPromise ??= fetch(sqlWasmUrl).then((response) => { @@ -79,6 +81,16 @@ async function loadSqlWasmBinary(): Promise { return await sqlWasmBinaryPromise; } +async function loadHdf5WasmBinary(): Promise { + hdf5WasmBinaryPromise ??= fetch(hdf5WasmUrl).then((response) => { + if (!response.ok) { + throw new Error(`Failed to load HDF5 wasm: HTTP ${response.status}`); + } + return response.arrayBuffer(); + }); + return await hdf5WasmBinaryPromise; +} + function extensionForDataset(ds: DatasetItem): string | undefined { if (ds.kind === 'file' && ds.file) { return ds.file.name.split('.').pop()?.toLowerCase(); @@ -124,6 +136,7 @@ async function initializePlayerForDataset( const workerPerf = typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('workerPerf') === '1'; const sqlWasmBinary = ext === 'db3' ? await loadSqlWasmBinary() : undefined; + const hdf5WasmBinary = ext === 'hdf5' || ext === 'h5' ? await loadHdf5WasmBinary() : undefined; if (ds.kind === 'url' && ds.url) { const init: Record = { url: resolveBrowserHttpUrl(ds.url), @@ -133,6 +146,9 @@ async function initializePlayerForDataset( if (ext === 'db3') { init.sqlWasmBinary = sqlWasmBinary; } + if (hdf5WasmBinary) { + init.hdf5WasmBinary = hdf5WasmBinary; + } if ( typeof ds.sizeBytes === 'number' && Number.isFinite(ds.sizeBytes) && @@ -155,7 +171,12 @@ async function initializePlayerForDataset( ...(sqlWasmBinary ? { sqlWasmBinary } : {}), }); } else { - await player.initialize({ file: ds.file, workerPerf, autoDataQualityScan }); + await player.initialize({ + file: ds.file, + workerPerf, + autoDataQualityScan, + ...(hdf5WasmBinary ? { hdf5WasmBinary } : {}), + }); } return; } diff --git a/src/infra/sources/hdf5/H5FileSystem.ts b/src/infra/sources/hdf5/H5FileSystem.ts index 2c151e2..503152f 100644 --- a/src/infra/sources/hdf5/H5FileSystem.ts +++ b/src/infra/sources/hdf5/H5FileSystem.ts @@ -2,7 +2,7 @@ * Bridges between our data source inputs (Blob / URL) and the Emscripten * in-memory filesystem that the `@ioai/hdf5` Emscripten module exposes. * - * Runs exclusively inside the Worker, after `import('@ioai/hdf5').default.ready`. + * Runs exclusively inside the Worker, after `initHdf5()` has initialized the runtime. * * - Local file (Blob): loaded fully into a Uint8Array and written to MEMFS. * Simple, self-contained, and avoids the restrictions of lazy files. @@ -84,6 +84,30 @@ export async function mountUrlAsLazyFile( try { h5.FS.unlink(path); } catch { /* best effort */ } } + // Small same-origin files are eager-fetched into MEMFS. Emscripten lazy URLs + // can fail in module workers, and tiny fixtures do not benefit from range IO. + try { + const headRes = await fetch(url, { method: 'HEAD' }); + const len = Number(headRes.headers.get('content-length') ?? 0); + if (headRes.ok && len > 0 && len <= 32 * 1024 * 1024) { + const res = await fetch(url); + if (res.ok) { + const buf = new Uint8Array(await res.arrayBuffer()); + h5.FS.writeFile(path, buf); + return { + path, + totalBytes: buf.byteLength, + lazy: false, + dispose: () => { + try { h5.FS.unlink(path); } catch { /* ignore */ } + }, + }; + } + } + } catch { + // Fall through to lazy mount for large or remote files. + } + let totalBytes = 0; try { const headRes = await fetch(url, { method: 'HEAD' }); diff --git a/src/infra/workers/hdf5.worker.ts b/src/infra/workers/hdf5.worker.ts index a63618f..a0ba3ff 100644 --- a/src/infra/workers/hdf5.worker.ts +++ b/src/infra/workers/hdf5.worker.ts @@ -1,4 +1,5 @@ import * as Comlink from 'comlink'; +import { loadHdf5Runtime, openHdf5File } from './hdf5RuntimeLoader'; import type { Initialization } from '@/core/types/ros'; import type { GetAdjacentMessageArgs, @@ -29,10 +30,17 @@ class Hdf5WorkerImpl implements IWorkerSerializedSourceWorker { }; private _qualityScan = new DataQualityScanController(); - async initialize(args: { url?: string; file?: Blob; autoDataQualityScan?: boolean }): Promise { - const h5mod = (await import('@ioai/hdf5')).default; - await h5mod.ready; - const h5 = h5mod as unknown as Parameters[0]; + async initialize(args: { + url?: string; + file?: Blob; + autoDataQualityScan?: boolean; + hdf5WasmBinary?: ArrayBuffer; + }): Promise { + if (!args.hdf5WasmBinary) { + throw new Error('Hdf5Worker: hdf5WasmBinary required (pass wasm bytes from main thread for inline workers)'); + } + const runtime = await loadHdf5Runtime(args.hdf5WasmBinary); + const h5 = { FS: runtime.module.FS, ready: Promise.resolve(runtime.module) }; if (args.file) { const name = (args.file as File).name ?? 'upload.h5'; @@ -43,9 +51,8 @@ class Hdf5WorkerImpl implements IWorkerSerializedSourceWorker { throw new Error('Hdf5Worker: neither url nor file provided'); } - const File = (h5mod as unknown as { File: new (path: string, mode: string) => unknown }).File; try { - this._h5file = new File(this._mounted.path, 'r') as typeof this._h5file; + this._h5file = openHdf5File(runtime, this._mounted.path, 'r'); } catch (err) { console.error('[Hdf5Worker] failed to open HDF5 file', err); throw err; diff --git a/src/infra/workers/hdf5RuntimeLoader.ts b/src/infra/workers/hdf5RuntimeLoader.ts new file mode 100644 index 0000000..d4bc5cf --- /dev/null +++ b/src/infra/workers/hdf5RuntimeLoader.ts @@ -0,0 +1,20 @@ +import { Hdf5File, initHdf5, type Hdf5Runtime } from '@ioai/hdf5'; + +let runtimePromise: Promise | undefined; + +/** + * Load @ioai/hdf5 for inline (`?worker&inline`) workers. + * + * Inline workers run from a blob: URL, so the main thread passes preloaded wasm + * bytes and lets @ioai/hdf5 initialize through its public API. + */ +export function loadHdf5Runtime(wasmBinary: ArrayBuffer): Promise { + if (!runtimePromise) { + runtimePromise = initHdf5({ wasmBinary }); + } + return runtimePromise; +} + +export function openHdf5File(runtime: Hdf5Runtime, memfsPath: string, mode: 'r' | 'a' | 'w' = 'r') { + return new Hdf5File(runtime, memfsPath, mode); +} diff --git a/src/shared/utils/resolveWorkerHttpUrl.ts b/src/shared/utils/resolveWorkerHttpUrl.ts index 84e7975..9e1a58a 100644 --- a/src/shared/utils/resolveWorkerHttpUrl.ts +++ b/src/shared/utils/resolveWorkerHttpUrl.ts @@ -1,12 +1,16 @@ /** - * Resolve URL for fetch() inside a Web Worker. - * Root-relative paths must use the document origin (`self.origin`), not the worker script URL. + * Resolve URL for fetch() / dynamic import() inside a Web Worker. + * Root-relative and build-relative asset paths must use the document origin + * (`self.location.href`), not the inline worker blob URL. */ export function resolveWorkerHttpUrl(url: string): string { if (/^https?:\/\//i.test(url)) { return url; } - if (url.startsWith("/") && typeof self !== "undefined" && self.origin) { + if (typeof self !== 'undefined' && 'location' in self && self.location?.href) { + return new URL(url, self.location.href).href; + } + if (url.startsWith('/') && typeof self !== 'undefined' && self.origin) { return new URL(url, self.origin).href; } return url; diff --git a/test-fixtures/layouts/agibot-like.json b/test-fixtures/layouts/agibot-like.json new file mode 100644 index 0000000..3a378ae --- /dev/null +++ b/test-fixtures/layouts/agibot-like.json @@ -0,0 +1,97 @@ +{ + "configById": { + "Canvas!2w1xadt": { + "topicPath": "/rgbd/color/image_raw/compressed" + }, + "3D!ekxxpf": { + "cameraState": { + "perspective": true, + "distance": 3.6805182047087133, + "phi": 65.10050734527103, + "thetaOffset": -89.72957057540752, + "targetOffset": [ + 0.21154086089697233, + 0.04711358421081375, + 7.50736472891447e-17 + ], + "target": [0, 0, 0], + "targetOrientation": [0, 0, 0, 1], + "fovy": 45, + "near": 0.5, + "far": 5000 + }, + "followMode": "follow-pose", + "scene": { + "transforms": { + "lineWidth": 0, + "axisScale": 0, + "showLabel": false, + "editable": false, + "enablePreloading": true + }, + "meshUpAxis": "y_up" + }, + "transforms": {}, + "topics": {}, + "layers": { + "8dcd6292-44f3-4daf-be37-d573a500d0e4": { + "visible": true, + "frameLocked": true, + "label": "URDF", + "instanceId": "8dcd6292-44f3-4daf-be37-d573a500d0e4", + "layerId": "foxglove.Urdf", + "sourceType": "topic", + "url": "", + "filePath": "", + "parameter": "", + "framePrefix": "", + "displayMode": "auto", + "fallbackColor": "#ffffff", + "order": 1, + "topic": "robot_description" + }, + "12b90a4c-e452-4aad-96e7-feece560b0f5": { + "visible": true, + "frameLocked": true, + "label": "Grid", + "instanceId": "12b90a4c-e452-4aad-96e7-feece560b0f5", + "layerId": "foxglove.Grid", + "size": 10, + "divisions": 10, + "lineWidth": 1, + "color": "#248eff", + "position": [0, 0, 0], + "rotation": [0, 0, 0], + "order": 2 + } + }, + "publish": { + "type": "point", + "poseTopic": "/move_base_simple/goal", + "pointTopic": "/clicked_point", + "poseEstimateTopic": "/initialpose", + "poseEstimateXDeviation": 0.5, + "poseEstimateYDeviation": 0.5, + "poseEstimateThetaDeviation": 0.26179939 + }, + "imageMode": {} + }, + "Joints!1t4fh4t": { + "topicPath": "io_teleop/joint_states" + } + }, + "globalVariables": { + "globalVariable": 0 + }, + "userNodes": {}, + "layout": { + "first": { + "first": "Canvas!2w1xadt", + "second": "3D!ekxxpf", + "direction": "row" + }, + "second": "Joints!1t4fh4t", + "direction": "column", + "splitPercentage": 56.470588235294116 + } +} diff --git a/test-fixtures/layouts/multi-canvas.json b/test-fixtures/layouts/multi-canvas.json new file mode 100644 index 0000000..4ea5526 --- /dev/null +++ b/test-fixtures/layouts/multi-canvas.json @@ -0,0 +1,117 @@ +{ + "layout": { + "first": { + "first": "Canvas!162kif3", + "second": { + "first": "Canvas!2mccubl", + "second": { + "first": "Canvas!3lvfzbu", + "second": "Canvas!284oaci", + "direction": "row", + "splitPercentage": 50.06835269993164 + }, + "direction": "row", + "splitPercentage": 33.33333333333334 + }, + "direction": "row", + "splitPercentage": 24.95090613252539 + }, + "second": { + "first": { + "first": "3D!2ofx34", + "second": "Canvas!3a8p53p", + "direction": "row", + "splitPercentage": 49.87459887216873 + }, + "second": { + "first": "Canvas!19w6ich", + "second": "Canvas!1755x3g", + "direction": "row" + }, + "direction": "row", + "splitPercentage": 50 + }, + "direction": "column", + "splitPercentage": 53.35029686174725 + }, + "userNodes": {}, + "configById": { + "3D!2ofx34": { + "scene": {}, + "layers": { + "122c4034-bdfd-4872-aa60-68a0df2747bc": { + "size": 10, + "color": "#248eff", + "label": "Grid", + "order": 1, + "layerId": "foxglove.Grid", + "visible": true, + "position": [0, 0, 0], + "rotation": [0, 0, 0], + "divisions": 10, + "lineWidth": 1, + "instanceId": "122c4034-bdfd-4872-aa60-68a0df2747bc", + "frameLocked": true + } + }, + "topics": { + "/vo_traj": { + "visible": true, + "showOutlines": true + } + }, + "publish": { + "type": "point", + "poseTopic": "/move_base_simple/goal", + "pointTopic": "/clicked_point", + "poseEstimateTopic": "/initialpose", + "poseEstimateXDeviation": 0.5, + "poseEstimateYDeviation": 0.5, + "poseEstimateThetaDeviation": 0.26179939 + }, + "imageMode": {}, + "followMode": "follow-pose", + "transforms": {}, + "cameraState": { + "far": 5000, + "phi": 67.06205985915402, + "fovy": 45, + "near": 0.5, + "target": [0, 0, 0], + "distance": 1.131232547004857, + "perspective": true, + "thetaOffset": 1.181778169014167, + "targetOffset": [ + 0.04282398404725626, + 0.1988218666116751, + -1.383210124033811e-18 + ], + "targetOrientation": [0, 0, 0, 1] + } + }, + "Canvas!162kif3": { + "topicPath": "/sensor/EgoCentric_Camera_0/image/compressed" + }, + "Canvas!1755x3g": { + "topicPath": "/camera/depth" + }, + "Canvas!19w6ich": { + "topicPath": "/ee_pose/vis/compressed" + }, + "Canvas!284oaci": { + "topicPath": "/sensor/Right_Gripper_Camera_0/image/compressed" + }, + "Canvas!2mccubl": { + "topicPath": "/sensor/EgoCentric_Camera_1/image/compressed" + }, + "Canvas!3a8p53p": { + "topicPath": "/semantic_segmentation/EgoCentric_Camera_0/compressed" + }, + "Canvas!3lvfzbu": { + "topicPath": "/sensor/Left_Wrist_Camera_0/image/compressed" + } + }, + "globalVariables": { + "globalVariable": 0 + } +} diff --git a/test-fixtures/media/h264-delta.bin b/test-fixtures/media/h264-delta.bin new file mode 100644 index 0000000000000000000000000000000000000000..f1525419dbf9f5cb0c13837027f071b0e73ef5bd GIT binary patch literal 10 RcmZQzU|@8dWg-1g0ssf30x19h literal 0 HcmV?d00001 diff --git a/test-fixtures/media/h264-key.bin b/test-fixtures/media/h264-key.bin new file mode 100644 index 0000000000000000000000000000000000000000..756e70c578a5b49b1b014b62227a23430b8113ac GIT binary patch literal 696 zcmX|9v5wO~5cP>bK|xJHF%r^M7iaAx=7`t=juUB+uA&2Ly&lI_Z0}}wb8)Cn;tIY1 z3F(mN_yj7X>+b^~5;YPHh%Y#1BZ6d&-^{$3d79ZE2=-=AE^lvo`P}`g;ET_~(}&o9X9w2j5_G|JUy>PDaoNsSHAtjKK`!IEHLEPS{vb zVj2=*@ciWXS$_nNUmOuLM@fkzrPnnU4r0cJ{g}mL3YO04$zZTrt-{kPM^%e9RAx5t zQ^L|UHF1=7m1^w-q{xKi3=9^02svi8l5-wS*o1*-MZLDjSx0^sMG-W}OYFcdGeQqQ z+cl58O-;_DkWmpCXsQnL)OYk1sS)iAd6a-GjcP^(PF6}rMnOeST|r4lSw=>~TvNxu(8R<)Zf(-wU zFvtT9X9aSAfB^~^nV4Bv+1NQaxwwG}whAyXF)}kVu`si;vakSE*8=4kSOi&x6b&8O zgaZ@Vl?p|S8YeE~Pwh=DOELf4NWZ*Q!{f5ODks=S2uSL zPp{yR(6I1`$f)F$)U@=B%&g*)(z5c3%Btp;*0%PJ&aO$5r%atTea6gLixw|gx@`H1 zm8&*w-m-Pu_8mKS9XfpE=&|D`PM*4S`O4L6*Kgds_3+W-Cr_U}fAR9w$4{TXeEs(Q b$IoB?Z!vIy{A17X`|53l6(8#&|K9`vKa_4C literal 0 HcmV?d00001 diff --git a/test-fixtures/media/minimal-aloha.h5 b/test-fixtures/media/minimal-aloha.h5 new file mode 100644 index 0000000000000000000000000000000000000000..7271c504e995c1c40a8062dd69b2d089f248f7e8 GIT binary patch literal 7440 zcmeHM&2G~`5FXn}>jnx=Tbees)cl8k^8(;X4T?BGMLBSb)5=1XC?V8TxshYJ^$~LH zkt4?(Ir0d7guVgHdS*hkO%zlVIRtMqnVFs4+1=0MwRiHRvi58`SImJbwhb9@Vo`p6 za&eZaMs%?UEEvr9nID=Y7@$4?3tXRP|9ZREugYb>mf~MHcLOg&k}DM{|F00Jlvmf) zg5U6}OU%D>oUsbEt!TUc9-zLt8-CoYDP>pLq8(}>aq3Bj0o;IW9KZCVB9ulpO!CBG z2^Ra&bqgb3wamLKcyFoVLcxZ@=U4{xZpU`hB!@QNDVBkTxac!&a?fPg#*3G02CkMT z2d*r<&vaxrX=bT`;NNClwi{RaFNrYTyx+d4<1%Pp+|LtEvcHZuYU%291p391Q=pdK zqTZvI-g=6)K7Nr}NNuDJ(h(A@0ys!6l7|!*pW3N5%C2laQh?M#IzVb8eMK7O60iHj z1ph!cOe>R}n6#&I=gv=Gxaj0(X6G)=7Zw(amzS2WthiUNUB7Yj*6q@ryZ7!tc=!mN zKhfyKc&V5okRp&GkRp&GkPHI)xvIj`aOK6F^~Q&Ke2!}Dh3`~VRQptYjyw#jXsJfr zO!S86+dH+lA@(%5Lfj^IuJT6sTr~mR)84!w*qggteLUCOF10=jokh>3y6$8W|cv?n{%$ z5iaT3L3RrhvC5NL4|${r@1LLcrf4XSM-&!_FjP)?=x&l64mJFTI`|klKOI-4cy;>! Y8xqG7N1~s7{|`8R { + requireExamplesDir(); +}); test('renders welcome screen initially', async ({ page }) => { await page.goto('/'); @@ -16,9 +13,6 @@ test('renders welcome screen initially', async ({ page }) => { test('can trigger file input from welcome screen', async ({ page }) => { await page.goto('/'); - // Clicking the button should trigger the hidden file input - // We can't easily test the file picker dialog in Playwright, - // but we can check if the button exists and is clickable. await expect(page.getByRole('button', { name: 'Choose file' })).toBeEnabled(); }); @@ -41,21 +35,15 @@ test('layout menu lists import, export, save, reset', async ({ page }) => { }); test('keyboard shortcuts work', async ({ page }) => { - test.skip( - !hasMcapFixture, - `Missing MCAP fixture: copy to public/examples/test_5s.mcap or set ROSVIEW_TEST_MCAP (${fixturePath})`, - ); - await page.goto('/?url=/examples/test_5s.mcap'); + await page.goto(`/?url=${MCAP_BASIC_URL}`); await expect(page.getByTestId('rosview-dockview')).toContainText('/camera/', { timeout: 15000 }); - // Blur focused input: sidebar topic filter can swallow Space before transport shortcuts. await page.evaluate(() => { const el = document.activeElement; if (el instanceof HTMLElement) el.blur(); }); - // Short fixture (~1s): assert Space toggles play/pause instead of wall-clock drift. await expect(page.getByRole('button', { name: 'Play playback' })).toBeVisible(); const loopMode = page.getByTestId('playback-loop-trigger'); await expect(loopMode).toBeVisible(); @@ -67,11 +55,7 @@ test('keyboard shortcuts work', async ({ page }) => { }); test('playback bar supports hover, drag and loop menu', async ({ page }) => { - test.skip( - !hasMcapFixture, - `Missing MCAP fixture: copy to public/examples/test_5s.mcap or set ROSVIEW_TEST_MCAP (${fixturePath})`, - ); - await page.goto('/?url=/examples/test_5s.mcap'); + await page.goto(`/?url=${MCAP_BASIC_URL}`); await expect(page.getByTestId('rosview-dockview')).toContainText('/camera/', { timeout: 15000 }); const track = page.getByTestId('playback-track'); @@ -101,11 +85,7 @@ test('playback bar supports hover, drag and loop menu', async ({ page }) => { }); test('playback updates image frames and supports sampling FPS switch', async ({ page }) => { - test.skip( - !hasMcapFixture, - `Missing MCAP fixture: copy to public/examples/test_5s.mcap or set ROSVIEW_TEST_MCAP (${fixturePath})`, - ); - await page.goto('/?url=/examples/test_5s.mcap'); + await page.goto(`/?url=${MCAP_BASIC_URL}`); await expect(page.getByTestId('rosview-dockview')).toContainText('/camera/', { timeout: 30_000 }); const fpsSelect = page.getByTestId('playback-fps-trigger'); @@ -114,7 +94,6 @@ test('playback updates image frames and supports sampling FPS switch', async ({ await page.getByTestId('playback-fps-option-15').click(); await expect(fpsSelect).toContainText('15'); - // Image panel renders to canvas (not ); wait for first frame after lazy init. const canvas = page.locator('canvas').first(); await expect(canvas).toBeVisible({ timeout: 45_000 }); await expect(page.getByRole('button', { name: 'Play playback' })).toBeVisible(); @@ -123,12 +102,8 @@ test('playback updates image frames and supports sampling FPS switch', async ({ }); test('dockview main region resizes with the window', async ({ page }) => { - test.skip( - !hasMcapFixture, - `Missing MCAP fixture: copy to public/examples/test_5s.mcap or set ROSVIEW_TEST_MCAP (${fixturePath})`, - ); const prev = page.viewportSize(); - await page.goto('/?url=/examples/test_5s.mcap'); + await page.goto(`/?url=${MCAP_BASIC_URL}`); await expect(page.getByTestId('rosview-dockview')).toContainText('/camera/', { timeout: 30_000 }); const dock = page.getByTestId('rosview-dockview'); diff --git a/tests/bvh-basic.spec.ts b/tests/bvh-basic.spec.ts new file mode 100644 index 0000000..e7e75df --- /dev/null +++ b/tests/bvh-basic.spec.ts @@ -0,0 +1,15 @@ +import { test, expect } from '@playwright/test'; +import { BVH_MINIMAL, BVH_MINIMAL_URL, requireFixture } from './fixturePaths'; + +test.describe.configure({ timeout: 60_000 }); + +test.beforeAll(() => { + requireFixture(BVH_MINIMAL); +}); + +test('BVH sample loads and exposes skeleton topic in the sidebar', async ({ page }) => { + await page.goto(`/?url=${BVH_MINIMAL_URL}`, { waitUntil: 'domcontentloaded' }); + + await expect(page.locator('body')).toContainText('/bvh/skeleton', { timeout: 30_000 }); + await expect(page.getByRole('button', { name: 'Play playback' })).toBeVisible(); +}); diff --git a/tests/delivery.spec.ts b/tests/delivery.spec.ts index e923b6a..0e4c1ce 100644 --- a/tests/delivery.spec.ts +++ b/tests/delivery.spec.ts @@ -1,12 +1,9 @@ import { test, expect } from '@playwright/test'; -import { existsSync } from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { MCAP_BASIC, MCAP_BASIC_URL, requireExamplesDir } from './fixturePaths'; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const defaultFixture = path.join(__dirname, '../public/examples/test_5s.mcap'); -const fixturePath = process.env.ROSVIEW_TEST_MCAP ?? defaultFixture; -const hasMcapFixture = existsSync(fixturePath); +test.beforeAll(() => { + requireExamplesDir(); +}); test('zh UI from ?lang= query', async ({ page }) => { await page.goto('/?lang=zh'); @@ -21,24 +18,24 @@ test('zh UI from ?lang=zh-CN query (SEO-friendly)', async ({ page }) => { }); test('remote single url opens fixture', async ({ page }) => { - test.skip( - !hasMcapFixture, - `Missing MCAP fixture: copy to public/examples/test_5s.mcap or set ROSVIEW_TEST_MCAP (${fixturePath})`, - ); - await page.goto('/?url=/examples/test_5s.mcap', { waitUntil: 'domcontentloaded' }); + await page.goto(`/?url=${MCAP_BASIC_URL}`, { waitUntil: 'domcontentloaded' }); await expect(page.getByTestId('rosview-dockview')).toBeVisible({ timeout: 60_000 }); await expect(page.getByTestId('rosview-dockview')).toContainText('/camera/', { timeout: 30_000 }); await expect(page.locator('nav')).toContainText('test_5s.mcap', { timeout: 15_000 }); }); test('dockview theme class follows light mode', async ({ page }) => { - test.skip( - !hasMcapFixture, - `Missing MCAP fixture: copy to public/examples/test_5s.mcap or set ROSVIEW_TEST_MCAP (${fixturePath})`, - ); - await page.goto('/?url=/examples/test_5s.mcap&theme=light'); + await page.goto(`/?url=${MCAP_BASIC_URL}&theme=light`); await expect(page.getByTestId('rosview-dockview')).toBeVisible({ timeout: 30_000 }); const dock = page.getByTestId('rosview-dockview'); await expect(dock).toHaveAttribute('data-dockview-chrome-theme', 'light'); await expect(page.locator('.ros-dockview-theme-light').first()).toBeVisible(); }); + +test.describe('local file upload', () => { + test('local file via welcome hidden file input', async ({ page }) => { + await page.goto('/'); + await page.locator('#rosview-landing-file').setInputFiles(MCAP_BASIC); + await expect(page.getByTestId('rosview-dockview')).toContainText('/camera/', { timeout: 30_000 }); + }); +}); diff --git a/tests/dockview-chrome.spec.ts b/tests/dockview-chrome.spec.ts index b2af6e8..fdff2ef 100644 --- a/tests/dockview-chrome.spec.ts +++ b/tests/dockview-chrome.spec.ts @@ -1,12 +1,5 @@ import { test, expect } from '@playwright/test'; -import { existsSync } from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const defaultFixture = path.join(__dirname, '../public/examples/test_5s.mcap'); -const fixturePath = process.env.ROSVIEW_TEST_MCAP ?? defaultFixture; -const hasMcapFixture = existsSync(fixturePath); +import { MCAP_BASIC_URL, requireExamplesDir } from './fixturePaths'; async function openFirstTabChromeMenuIfNeeded(page: import('@playwright/test').Page): Promise { const directAdd = page.getByTestId('panel-tab-add-button').first(); @@ -21,12 +14,12 @@ async function openFirstTabChromeMenuIfNeeded(page: import('@playwright/test').P test.describe('Dockview chrome', () => { test.describe.configure({ timeout: 90_000 }); + test.beforeAll(() => { + requireExamplesDir(); + }); + test('shows dockview, group add split, and tab close control', async ({ page }) => { - test.skip( - !hasMcapFixture, - `Missing MCAP fixture: copy to public/examples/test_5s.mcap or set ROSVIEW_TEST_MCAP (${fixturePath})`, - ); - await page.goto('/?url=/examples/test_5s.mcap', { waitUntil: 'domcontentloaded' }); + await page.goto(`/?url=${MCAP_BASIC_URL}`, { waitUntil: 'domcontentloaded' }); await expect(page.getByTestId('rosview-dockview')).toBeVisible({ timeout: 60_000 }); await openFirstTabChromeMenuIfNeeded(page); await expect(page.getByTestId('panel-tab-add-button').first()).toBeVisible({ timeout: 30_000 }); @@ -34,11 +27,7 @@ test.describe('Dockview chrome', () => { }); test('primary add creates a new tab in the group', async ({ page }) => { - test.skip( - !hasMcapFixture, - `Missing MCAP fixture: copy to public/examples/test_5s.mcap or set ROSVIEW_TEST_MCAP (${fixturePath})`, - ); - await page.goto('/?url=/examples/test_5s.mcap', { waitUntil: 'domcontentloaded' }); + await page.goto(`/?url=${MCAP_BASIC_URL}`, { waitUntil: 'domcontentloaded' }); await expect(page.getByTestId('rosview-dockview')).toBeVisible({ timeout: 60_000 }); await openFirstTabChromeMenuIfNeeded(page); await expect(page.getByTestId('panel-tab-add-button').first()).toBeVisible({ timeout: 30_000 }); @@ -56,11 +45,7 @@ test.describe('Dockview chrome', () => { }); test('tab context menu offers Close', async ({ page }) => { - test.skip( - !hasMcapFixture, - `Missing MCAP fixture: copy to public/examples/test_5s.mcap or set ROSVIEW_TEST_MCAP (${fixturePath})`, - ); - await page.goto('/?url=/examples/test_5s.mcap', { waitUntil: 'domcontentloaded' }); + await page.goto(`/?url=${MCAP_BASIC_URL}`, { waitUntil: 'domcontentloaded' }); await expect(page.getByTestId('rosview-dockview')).toBeVisible({ timeout: 60_000 }); const firstTab = page.locator('.dv-tab').first(); await firstTab.click({ button: 'right' }); @@ -69,11 +54,7 @@ test.describe('Dockview chrome', () => { }); test('tab labels show panel type (not topic basename)', async ({ page }) => { - test.skip( - !hasMcapFixture, - `Missing MCAP fixture: copy to public/examples/test_5s.mcap or set ROSVIEW_TEST_MCAP (${fixturePath})`, - ); - await page.goto('/?url=/examples/test_5s.mcap', { waitUntil: 'domcontentloaded' }); + await page.goto(`/?url=${MCAP_BASIC_URL}`, { waitUntil: 'domcontentloaded' }); await expect(page.getByTestId('rosview-dockview')).toBeVisible({ timeout: 60_000 }); await expect(page.locator('.dv-tab').filter({ hasText: /^Image$/ }).first()).toBeVisible({ timeout: 30_000, @@ -81,11 +62,7 @@ test.describe('Dockview chrome', () => { }); test('dockview theme class follows dark mode', async ({ page }) => { - test.skip( - !hasMcapFixture, - `Missing MCAP fixture: copy to public/examples/test_5s.mcap or set ROSVIEW_TEST_MCAP (${fixturePath})`, - ); - await page.goto('/?url=/examples/test_5s.mcap&theme=dark', { waitUntil: 'domcontentloaded' }); + await page.goto(`/?url=${MCAP_BASIC_URL}&theme=dark`, { waitUntil: 'domcontentloaded' }); await expect(page.getByTestId('rosview-dockview')).toBeVisible({ timeout: 60_000 }); const dock = page.getByTestId('rosview-dockview'); await expect(dock).toHaveAttribute('data-dockview-chrome-theme', 'dark'); diff --git a/tests/fixturePaths.ts b/tests/fixturePaths.ts new file mode 100644 index 0000000..abe28e1 --- /dev/null +++ b/tests/fixturePaths.ts @@ -0,0 +1,33 @@ +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export const EXAMPLES_DIR = path.join(__dirname, '../public/examples'); + +export const MCAP_BASIC = path.join(EXAMPLES_DIR, 'test_5s.mcap'); +export const MCAP_POSE = path.join(EXAMPLES_DIR, 'test_pose.mcap'); +export const MCAP_3CAM = path.join(EXAMPLES_DIR, 'test_3cam.mcap'); +export const MCAP_H264 = path.join(EXAMPLES_DIR, 'test_h264.mcap'); +export const HDF5_MINIMAL = path.join(EXAMPLES_DIR, 'test_minimal.hdf5'); +export const BVH_MINIMAL = path.join(EXAMPLES_DIR, 'test_minimal.bvh'); + +export const MCAP_BASIC_URL = '/examples/test_5s.mcap'; +export const MCAP_POSE_URL = '/examples/test_pose.mcap'; +export const MCAP_3CAM_URL = '/examples/test_3cam.mcap'; +export const MCAP_H264_URL = '/examples/test_h264.mcap'; +export const HDF5_MINIMAL_URL = '/examples/test_minimal.hdf5'; +export const BVH_MINIMAL_URL = '/examples/test_minimal.bvh'; + +/** Fail fast when pretest:e2e has not generated fixtures. */ +export function requireFixture(fixturePath: string): string { + if (!existsSync(fixturePath)) { + throw new Error(`Missing fixture: ${fixturePath}. Run npm run gen:e2e:fixtures`); + } + return fixturePath; +} + +export function requireExamplesDir(): void { + requireFixture(MCAP_BASIC); +} diff --git a/tests/hdf5-basic.spec.ts b/tests/hdf5-basic.spec.ts index b404d94..00864dc 100644 --- a/tests/hdf5-basic.spec.ts +++ b/tests/hdf5-basic.spec.ts @@ -1,59 +1,35 @@ import { test, expect } from '@playwright/test'; -import { existsSync } from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const defaultFixture = path.join(__dirname, '../public/examples/episode_20260122_122345.hdf5'); -const fixturePath = process.env.ROSVIEW_TEST_HDF5 ?? defaultFixture; -const hasFixture = existsSync(fixturePath); -const fixtureUrlName = path.basename(fixturePath); - -// The 245 MB sample takes a while to load even over lazy ranges. -test.describe.configure({ mode: 'serial', timeout: 300_000 }); - -if (!hasFixture) { - console.warn( - `[e2e] hdf5-basic.spec.ts not registered: missing fixture public/examples/${fixtureUrlName} ` + - `or ROSVIEW_TEST_HDF5 (${fixturePath})`, - ); -} - -if (hasFixture) { +import { HDF5_MINIMAL_URL, requireFixture, HDF5_MINIMAL } from './fixturePaths'; + +test.describe.configure({ mode: 'serial', timeout: 120_000 }); + +test.beforeAll(() => { + requireFixture(HDF5_MINIMAL); +}); + test('HDF5 sample loads and exposes synthesized ROS topics in the sidebar', async ({ page }) => { page.on('pageerror', (err) => console.log('[browser:pageerror]', err.message)); - await page.goto(`/?url=/examples/${fixtureUrlName}`); + await page.goto(`/?url=${HDF5_MINIMAL_URL}`); - // Once the HDF5 worker has initialized, the sidebar lists the synthesized - // virtual topics. This is our readiness signal (the dockview shows only the - // Welcome placeholder by default until the user pins a panel). await expect(page.locator('body')).toContainText('/observations/joint_states', { - timeout: 180_000, + timeout: 60_000, }); await expect(page.locator('body')).toContainText('/observations/images/ext1'); await expect(page.locator('body')).toContainText('/observations/ee_pose'); await expect(page.locator('body')).toContainText('/action'); - // Playback controls must be present once the source is ready. await expect(page.getByRole('button', { name: 'Play playback' })).toBeVisible(); }); test('HDF5 image topic can be opened into an Image panel', async ({ page }) => { - await page.goto(`/?url=/examples/${fixtureUrlName}`); + await page.goto(`/?url=${HDF5_MINIMAL_URL}`); - // Wait for topics to show up in the sidebar. Use getByText (text engine) - // with an explicit string so Playwright doesn't misinterpret the leading - // slash as a regex flag. const imageTopicRow = page.getByText('/observations/images/ext1', { exact: false }).first(); - await expect(imageTopicRow).toBeVisible({ timeout: 180_000 }); + await expect(imageTopicRow).toBeVisible({ timeout: 60_000 }); - // Click to add as a panel. The sidebar entry is clickable and defaults to - // opening the Image panel for image-typed topics. await imageTopicRow.click(); - // The Image panel renders into a . Wait for it. const canvas = page.locator('canvas').first(); await expect(canvas).toBeVisible({ timeout: 45_000 }); }); -} diff --git a/tests/image-h264.spec.ts b/tests/image-h264.spec.ts index 2c57d5e..32ecb97 100644 --- a/tests/image-h264.spec.ts +++ b/tests/image-h264.spec.ts @@ -1,23 +1,14 @@ import { test, expect } from '@playwright/test'; -import { existsSync } from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { MCAP_H264_URL, requireFixture, MCAP_H264 } from './fixturePaths'; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const fixturePathCandidates = [ - path.join(__dirname, '../public/examples/episode_00004_2026_04_22_08_41_30.mcap'), - path.join(process.cwd(), 'public/examples/episode_00004_2026_04_22_08_41_30.mcap'), -]; -const fixturePath = fixturePathCandidates.find((p) => existsSync(p)) ?? fixturePathCandidates[0]; -const hasFixture = existsSync(fixturePath); +test.describe.configure({ timeout: 120_000 }); -if (!hasFixture) { - console.warn(`[e2e] image-h264.spec.ts not registered: missing sample MCAP ${fixturePath}`); -} +test.beforeAll(() => { + requireFixture(MCAP_H264); +}); -if (hasFixture) { -test('H.264 CompressedImage decodes without error (episode mcap)', async ({ page }) => { - await page.goto('/?url=/examples/episode_00004_2026_04_22_08_41_30.mcap', { waitUntil: 'domcontentloaded' }); +test('H.264 CompressedImage decodes without error', async ({ page }) => { + await page.goto(`/?url=${MCAP_H264_URL}`, { waitUntil: 'domcontentloaded' }); await expect(page.getByTestId('rosview-dockview')).toBeVisible({ timeout: 60_000 }); const play = page.getByRole('button', { name: 'Play playback' }); @@ -30,11 +21,9 @@ test('H.264 CompressedImage decodes without error (episode mcap)', async ({ page const hasDecodeFailure = await page.getByText(/decode failed|could not be decoded/i).count(); expect(hasDecodeFailure).toBe(0); - // Image surface may render via OffscreenCanvas (no readable 2D on the DOM canvas); rely on worker UI + no errors above. const imageStatus = page.getByTestId('image-panel-status'); if (await page.getByTestId('image-panel-canvas').isVisible().catch(() => false)) { await expect(imageStatus).toBeVisible({ timeout: 90_000 }); await expect(imageStatus).toHaveText(/\d+x\d+/); } }); -} diff --git a/tests/multi-sources.spec.ts b/tests/multi-sources.spec.ts index 3523618..5092c64 100644 --- a/tests/multi-sources.spec.ts +++ b/tests/multi-sources.spec.ts @@ -1,30 +1,20 @@ import { test, expect } from '@playwright/test'; -import { existsSync } from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const defaultFixture = path.join(__dirname, '../public/examples/test_5s.mcap'); -const fixturePath = process.env.ROSVIEW_TEST_MCAP ?? defaultFixture; -const hasMcapFixture = existsSync(fixturePath); +import { MCAP_BASIC, MCAP_BASIC_URL, requireExamplesDir } from './fixturePaths'; test.describe('multi-source URLs and sidebar', () => { - test.beforeEach(() => { - test.skip( - !hasMcapFixture, - `Missing MCAP fixture: copy to public/examples/test_5s.mcap or set ROSVIEW_TEST_MCAP (current ${fixturePath})`, - ); + test.beforeAll(() => { + requireExamplesDir(); }); test('single url= opens fixture', async ({ page }) => { - await page.goto('/?url=/examples/test_5s.mcap'); + await page.goto(`/?url=${MCAP_BASIC_URL}`); await expect(page.getByTestId('rosview-dockview')).toContainText('/camera/', { timeout: 30_000 }); await expect(page.getByTestId('playback-loaded-range').first()).toBeVisible(); }); test('local file via welcome hidden file input', async ({ page }) => { await page.goto('/'); - await page.locator('#rosview-landing-file').setInputFiles(fixturePath); + await page.locator('#rosview-landing-file').setInputFiles(MCAP_BASIC); await expect(page.getByTestId('rosview-dockview')).toContainText('/camera/', { timeout: 30_000 }); }); }); diff --git a/tests/pose-panel.spec.ts b/tests/pose-panel.spec.ts index dfdd0b8..4465e3d 100644 --- a/tests/pose-panel.spec.ts +++ b/tests/pose-panel.spec.ts @@ -1,23 +1,12 @@ import { test, expect } from '@playwright/test'; -import { existsSync } from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { MCAP_POSE_URL, requireFixture, MCAP_POSE } from './fixturePaths'; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const fixtureCandidates = [ - path.join(__dirname, '../public/examples/episode_00689_2026_04_01_07_14_56.mcap'), - path.join(process.cwd(), 'public/examples/episode_00689_2026_04_01_07_14_56.mcap'), -]; -const fixturePath = fixtureCandidates.find((candidate) => existsSync(candidate)) ?? fixtureCandidates[0]; -const hasFixture = existsSync(fixturePath); - -if (!hasFixture) { - console.warn(`[e2e] pose-panel.spec.ts not registered: missing sample MCAP ${fixturePath}`); -} +test.beforeAll(() => { + requireFixture(MCAP_POSE); +}); -if (hasFixture) { test('PoseStamped fixture exposes pose topics by schema', async ({ page }) => { - await page.goto('/?url=/examples/episode_00689_2026_04_01_07_14_56.mcap', { + await page.goto(`/?url=${MCAP_POSE_URL}`, { waitUntil: 'domcontentloaded', }); await expect(page.getByTestId('rosview-dockview')).toBeVisible({ timeout: 60_000 }); @@ -28,4 +17,3 @@ test('PoseStamped fixture exposes pose topics by schema', async ({ page }) => { await expect(page.getByText('/io/pose/Left_Gripper')).toBeVisible({ timeout: 30_000 }); await expect(page.getByText('/io/pose/Right_Gripper')).toBeVisible({ timeout: 30_000 }); }); -} diff --git a/tests/ros-image-grid.spec.ts b/tests/ros-image-grid.spec.ts index b56b099..1c6da68 100644 --- a/tests/ros-image-grid.spec.ts +++ b/tests/ros-image-grid.spec.ts @@ -1,18 +1,12 @@ import { test, expect } from '@playwright/test'; -import { existsSync } from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { MCAP_3CAM_URL, requireFixture, MCAP_3CAM } from './fixturePaths'; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const fixtureName = 'kaixiangzi52_2026-04-16_17-25-08_0.mcap'; -const fixturePath = path.join(__dirname, '../public/examples', fixtureName); -const hasFixture = existsSync(fixturePath); +test.describe.configure({ timeout: 120_000 }); -if (!hasFixture) { - console.warn(`[e2e] ros-image-grid.spec.ts not registered: missing sample MCAP ${fixturePath}`); -} +test.beforeAll(() => { + requireFixture(MCAP_3CAM); +}); -if (hasFixture) { test('loads the three-camera compressed image sample without empty decoder payloads', async ({ page }) => { const pageErrors: string[] = []; page.on('pageerror', (error) => pageErrors.push(error.message)); @@ -22,7 +16,7 @@ test('loads the three-camera compressed image sample without empty decoder paylo } }); - await page.goto(`/?url=/examples/${fixtureName}`, { waitUntil: 'domcontentloaded' }); + await page.goto(`/?url=${MCAP_3CAM_URL}`, { waitUntil: 'domcontentloaded' }); await expect(page.getByTestId('rosview-dockview')).toBeVisible({ timeout: 60_000 }); const imagePanels = page.getByTestId('image-panel'); @@ -47,4 +41,3 @@ test('loads the three-camera compressed image sample without empty decoder paylo await expect(page.getByText(/Image decode failed|Compressed image payload is empty|No image data provided/i)).toHaveCount(0); expect(pageErrors.filter((entry) => /ImageDecoder|No image data provided/i.test(entry))).toEqual([]); }); -} diff --git a/tests/transport-fallback.spec.ts b/tests/transport-fallback.spec.ts index bb1f6c7..09d6594 100644 --- a/tests/transport-fallback.spec.ts +++ b/tests/transport-fallback.spec.ts @@ -1,37 +1,26 @@ -import { test, expect } from "@playwright/test"; -import { existsSync } from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; +import { test, expect } from '@playwright/test'; +import { MCAP_BASIC_URL, requireExamplesDir } from './fixturePaths'; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const defaultFixture = path.join(__dirname, "../public/examples/test_5s.mcap"); -const fixturePath = process.env.ROSVIEW_TEST_MCAP ?? defaultFixture; -const hasMcapFixture = existsSync(fixturePath); - -test.describe("transport fallback", () => { - test.beforeEach(() => { - test.skip( - !hasMcapFixture, - `Missing MCAP fixture: copy to public/examples/test_5s.mcap or set ROSVIEW_TEST_MCAP (current ${fixturePath})`, - ); +test.describe('transport fallback', () => { + test.beforeAll(() => { + requireExamplesDir(); }); - test("exposes selected transport mode on dockview shell", async ({ page }) => { - await page.goto("/?url=/examples/test_5s.mcap"); - await expect(page.getByTestId("rosview-dockview")).toContainText("/camera/", { timeout: 30_000 }); - await expect(page.getByTestId("rosview-dockview")).toHaveAttribute("data-transport-mode", /^(sab|transfer|comlink)$/); + test('exposes selected transport mode on dockview shell', async ({ page }) => { + await page.goto(`/?url=${MCAP_BASIC_URL}`); + await expect(page.getByTestId('rosview-dockview')).toContainText('/camera/', { timeout: 30_000 }); + await expect(page.getByTestId('rosview-dockview')).toHaveAttribute('data-transport-mode', /^(sab|transfer|comlink)$/); }); - test("query parameter forces transfer mode", async ({ page }) => { - await page.goto("/?url=/examples/test_5s.mcap&transport=transfer"); - await expect(page.getByTestId("rosview-dockview")).toContainText("/camera/", { timeout: 30_000 }); - await expect(page.getByTestId("rosview-dockview")).toHaveAttribute("data-transport-mode", "transfer"); + test('query parameter forces transfer mode', async ({ page }) => { + await page.goto(`/?url=${MCAP_BASIC_URL}&transport=transfer`); + await expect(page.getByTestId('rosview-dockview')).toContainText('/camera/', { timeout: 30_000 }); + await expect(page.getByTestId('rosview-dockview')).toHaveAttribute('data-transport-mode', 'transfer'); }); - test("query parameter forces comlink mode", async ({ page }) => { - await page.goto("/?url=/examples/test_5s.mcap&transport=comlink"); - await expect(page.getByTestId("rosview-dockview")).toContainText("/camera/", { timeout: 30_000 }); - await expect(page.getByTestId("rosview-dockview")).toHaveAttribute("data-transport-mode", "comlink"); + test('query parameter forces comlink mode', async ({ page }) => { + await page.goto(`/?url=${MCAP_BASIC_URL}&transport=comlink`); + await expect(page.getByTestId('rosview-dockview')).toContainText('/camera/', { timeout: 30_000 }); + await expect(page.getByTestId('rosview-dockview')).toHaveAttribute('data-transport-mode', 'comlink'); }); }); - diff --git a/vite.config.ts b/vite.config.ts index de7a209..cd55dbb 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -67,7 +67,10 @@ export default defineConfig({ }, output: { format: 'es', - codeSplitting: true, + // @ioai/hdf5 dynamic import must stay in the worker bundle — splitting it + // emits a sibling chunk (dist-*.js) that workers resolve with a broken + // relative URL under preview/assets/. + codeSplitting: false, }, }, }, From 793316d344093ffab24958a2f43a35a63b3357be Mon Sep 17 00:00:00 2001 From: joaner Date: Wed, 27 May 2026 17:18:40 +0800 Subject: [PATCH 2/2] chore: bump to 1.3.2 and point SEO URLs to rosview.com Use patch semver for HDF5/fixture fixes without public API changes, and update index.html canonical/hreflang/OG links from io-ai.tech/rosview to rosview.com. --- index.html | 22 +++++++++++----------- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/index.html b/index.html index c81c1f1..55df9d2 100644 --- a/index.html +++ b/index.html @@ -18,16 +18,16 @@ - - - - + + + + - + - + - + - +