From 11b026d315d9f31aedc4f989cbd3bd07faabb74c Mon Sep 17 00:00:00 2001 From: DjDeveloperr Date: Fri, 1 May 2026 16:22:42 -0400 Subject: [PATCH 1/3] Improve CI caching and reliability coverage --- .github/workflows/ci.yml | 190 ++++++++++++++- .gitignore | 1 + docs/guide/testing.md | 23 ++ package.json | 2 + scripts/integration/cli.mjs | 119 +--------- scripts/integration/fixture.mjs | 233 +++++++++++++++++++ scripts/integration/js-api.mjs | 119 +--------- scripts/integration/prebuild-fixture.mjs | 23 ++ scripts/stress/simdeck.mjs | 284 +++++++++++++++++++++++ server/src/api/routes.rs | 244 +++++++++++++++++++ server/src/config.rs | 59 +++++ server/src/metrics/counters.rs | 111 +++++++++ server/src/native/bridge.rs | 94 ++++++++ server/src/transport/webrtc.rs | 211 ++++++++++++++++- 14 files changed, 1480 insertions(+), 233 deletions(-) create mode 100644 scripts/integration/fixture.mjs create mode 100755 scripts/integration/prebuild-fixture.mjs create mode 100755 scripts/stress/simdeck.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 82c3c7a3..9e1c0a07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,10 +6,49 @@ on: - main pull_request: +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_INCREMENTAL: "0" + jobs: - ci: + rust: + name: Rust lint and unit tests runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache Rust build outputs + uses: actions/cache@v4 + with: + path: | + ~/.cargo/git + ~/.cargo/registry + server/target + key: rust-${{ runner.os }}-${{ hashFiles('server/Cargo.lock', 'server/Cargo.toml', 'server/build.rs', 'server/src/**/*.rs', 'cli/**/*.m') }} + restore-keys: | + rust-${{ runner.os }}- + + - name: Check Rust formatting + run: cargo fmt --manifest-path server/Cargo.toml --check + + - name: Clippy + run: cargo clippy --manifest-path server/Cargo.toml --all-targets -- -D warnings + + - name: Rust unit tests + run: cargo test --manifest-path server/Cargo.toml + + client: + name: Client lint, build, and tests + runs-on: ubuntu-latest + steps: - uses: actions/checkout@v4 @@ -20,32 +59,165 @@ jobs: cache-dependency-path: | package-lock.json client/package-lock.json + + - name: Install root dependencies + run: npm ci --ignore-scripts --force + + - name: Install client dependencies + run: npm ci --prefix client + + - name: Check Prettier formatting + run: npx prettier --check . + + - name: Typecheck client + run: npm run --prefix client typecheck + + - name: Test client + run: npm run --prefix client test + + - name: Build client + run: npm run --prefix client build + + packages: + name: Packages and VS Code extension + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: | + package-lock.json packages/react-native-inspector/package-lock.json packages/nativescript-inspector/package-lock.json + - name: Install root dependencies + run: npm ci --ignore-scripts --force + + - name: Install NativeScript inspector dependencies + run: npm ci --prefix packages/nativescript-inspector + + - name: Install React Native inspector dependencies + run: npm ci --prefix packages/react-native-inspector + + - name: Build inspector and test packages + run: npm run build:packages + + - name: Package VS Code extension + run: npm run package:vscode-extension + + build-artifacts: + name: Build integration artifacts + runs-on: macos-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: | + package-lock.json + client/package-lock.json + - uses: dtolnay/rust-toolchain@stable + + - name: Cache Rust build outputs and fixture app + uses: actions/cache@v4 with: - components: rustfmt, clippy + path: | + ~/.cargo/git + ~/.cargo/registry + server/target + .cache/simdeck/fixture + key: rust-${{ runner.os }}-${{ hashFiles('server/Cargo.lock', 'server/Cargo.toml', 'server/build.rs', 'server/src/**/*.rs', 'cli/**/*.m', 'scripts/integration/fixture.mjs') }} + restore-keys: | + rust-${{ runner.os }}- - name: Install root dependencies - run: npm ci + run: npm ci --ignore-scripts - name: Install client dependencies run: npm ci --prefix client - - name: Install NativeScript inspector dependencies - run: npm ci --prefix packages/nativescript-inspector + - name: Build CLI, client, and JS test API + run: | + npm run build:cli + npm run build:client + npm run build:simdeck-test + npm run test:integration:fixture - - name: Install React Native inspector dependencies - run: npm ci --prefix packages/react-native-inspector + - name: Upload integration artifacts + uses: actions/upload-artifact@v4 + with: + name: simdeck-integration-artifacts + if-no-files-found: error + include-hidden-files: true + path: | + build/simdeck + build/simdeck-bin + client/dist + packages/simdeck-test/dist + .cache/simdeck/fixture + + integration-cli: + name: CLI simulator integration + runs-on: macos-latest + needs: + - rust + - client + - packages + - build-artifacts - - name: Lint, build, and test - run: npm run ci + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Download integration artifacts + uses: actions/download-artifact@v4 + with: + name: simdeck-integration-artifacts + path: . + + - name: Make CLI executable + run: chmod +x build/simdeck build/simdeck-bin - name: CLI simulator integration tests run: npm run test:integration:cli env: SIMDECK_INTEGRATION_VERBOSE: "1" + integration-js-api: + name: JS API simulator integration + runs-on: macos-latest + needs: + - rust + - client + - packages + - build-artifacts + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Download integration artifacts + uses: actions/download-artifact@v4 + with: + name: simdeck-integration-artifacts + path: . + + - name: Make CLI executable + run: chmod +x build/simdeck build/simdeck-bin + - name: JS API simulator integration tests run: npm run test:integration:js-api diff --git a/.gitignore b/.gitignore index 847fd936..88c21a83 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ packages/react-native-inspector/dist/ docs/.vitepress/dist/ docs/.vitepress/cache/ cloud/ +.cache/ .playwright-mcp/ simdeck-snapshot.md diff --git a/docs/guide/testing.md b/docs/guide/testing.md index 0f694e75..b68058a5 100644 --- a/docs/guide/testing.md +++ b/docs/guide/testing.md @@ -53,6 +53,7 @@ The repo includes a macOS-only integration runner that creates a temporary simul ```sh npm run build:cli npm run build:client +npm run test:integration:fixture npm run test:integration:cli ``` @@ -72,3 +73,25 @@ Useful environment variables: | `SIMDECK_INTEGRATION_KEEP_SIMULATOR=1` | Leave the temporary simulator after exit. | The integration suite is separate from `npm run test` because it boots and drives a real iOS simulator. +The SwiftUI fixture app is cached under `.cache/simdeck/fixture` using a hash +of its generated source, plist, simulator SDK, Swift compiler, and host +architecture. + +## Stress and Leak Checks + +Use the stress runner against an already-running daemon when you want to shake out +high-usage reliability issues without adding minutes to every PR: + +```sh +npm run test:stress -- --server-url http://127.0.0.1:4310 --iterations 1000 --concurrency 12 +``` + +To include simulator-specific refresh traffic and RSS growth checks: + +```sh +npm run test:stress -- --udid --iterations 2000 --concurrency 16 --max-rss-growth-mb 256 +``` + +The runner repeatedly calls health, metrics, simulator listing, stream-quality, +and optional simulator refresh endpoints. It samples the daemon process RSS with +`ps` and fails if the peak or growth limits are exceeded. diff --git a/package.json b/package.json index c0124b6f..4bea9ea5 100644 --- a/package.json +++ b/package.json @@ -62,8 +62,10 @@ "test": "cargo test --manifest-path server/Cargo.toml && npm run --prefix client test", "test:integration:cli": "node scripts/integration/cli.mjs", "test:integration:cli:verbose": "SIMDECK_INTEGRATION_VERBOSE=1 SIMDECK_INTEGRATION_SHOW_SIMULATOR=1 node scripts/integration/cli.mjs", + "test:integration:fixture": "node scripts/integration/prebuild-fixture.mjs", "test:integration:js-api": "node scripts/integration/js-api.mjs", "test:integration:js-api:verbose": "SIMDECK_INTEGRATION_SHOW_SIMULATOR=1 node scripts/integration/js-api.mjs", + "test:stress": "node scripts/stress/simdeck.mjs", "ci": "npm run lint && npm run build:all && npm run test && npm run package:vscode-extension", "dev": "npm run build:cli && node scripts/dev.mjs", "preview:swiftui": "node scripts/experimental/swiftui-preview.mjs", diff --git a/scripts/integration/cli.mjs b/scripts/integration/cli.mjs index 9d11c2a2..4dac06e7 100644 --- a/scripts/integration/cli.mjs +++ b/scripts/integration/cli.mjs @@ -4,6 +4,7 @@ import fs from "node:fs"; import http from "node:http"; import os from "node:os"; import path from "node:path"; +import { buildCachedFixtureApp } from "./fixture.mjs"; const root = path.resolve(new URL("../..", import.meta.url).pathname); const simdeck = path.join(root, "build", "simdeck"); @@ -697,117 +698,13 @@ function compareRuntimeVersions(left, right) { } function buildFixtureApp() { - const appPath = path.join(tempRoot, "SimDeckFixture.app"); - fs.mkdirSync(appPath, { recursive: true }); - const executable = "SimDeckFixture"; - fs.writeFileSync( - path.join(appPath, "Info.plist"), - ` - - - - CFBundleDevelopmentRegionen - CFBundleExecutable${executable} - CFBundleIdentifier${fixtureBundleId} - CFBundleInfoDictionaryVersion6.0 - CFBundleNameSimDeckFixture - CFBundlePackageTypeAPPL - CFBundleShortVersionString1.0 - CFBundleVersion1 - LSRequiresIPhoneOS - MinimumOSVersion15.0 - UIDeviceFamily1 - CFBundleURLTypes - - - CFBundleURLNameSimDeckFixture - CFBundleURLSchemes - simdeck-fixture - - - - -`, - ); - const main = path.join(tempRoot, "SimDeckFixture.swift"); - fs.writeFileSync( - main, - `import SwiftUI - -struct FixtureView: View { - @State private var status = "Integration Ready" - @State private var tapCount = 0 - @State private var message = "" - @FocusState private var messageFocused: Bool - - var body: some View { - VStack(spacing: 24) { - Text("SimDeck Fixture") - .font(.title2) - .accessibilityIdentifier("fixture.title") - - Text(status) - .accessibilityIdentifier("fixture.status") - - Button("Continue") { - tapCount += 1 - status = "Continue Tapped \\(tapCount)" - } - .buttonStyle(.borderedProminent) - .accessibilityIdentifier("fixture.continue") - - TextField("Message", text: $message) - .textFieldStyle(.roundedBorder) - .accessibilityIdentifier("fixture.message") - .textInputAutocapitalization(.never) - .autocorrectionDisabled(true) - .focused($messageFocused) - .frame(width: 240) - } - .padding() - .onOpenURL { url in - if url.host == "focus-message" { - status = "Message Focused" - messageFocused = true - } else { - status = "URL Opened" - } - } - } -} - -@main -struct SimDeckFixtureApp: App { - var body: some Scene { - WindowGroup { - FixtureView() - } - } -} -`, - ); - const targetArch = process.arch === "arm64" ? "arm64" : "x86_64"; - runText( - "xcrun", - [ - "--sdk", - "iphonesimulator", - "swiftc", - "-target", - `${targetArch}-apple-ios15.0-simulator`, - "-parse-as-library", - "-Onone", - "-framework", - "SwiftUI", - "-framework", - "UIKit", - main, - "-o", - path.join(appPath, executable), - ], - { timeoutMs: 300_000 }, - ); - return { appPath }; + return buildCachedFixtureApp({ + root, + tempRoot, + bundleId: fixtureBundleId, + urlScheme: fixtureUrlScheme, + log: logStep, + }); } function startServer() { diff --git a/scripts/integration/fixture.mjs b/scripts/integration/fixture.mjs new file mode 100644 index 00000000..b5d3162b --- /dev/null +++ b/scripts/integration/fixture.mjs @@ -0,0 +1,233 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +const executable = "SimDeckFixture"; +const minimumIosVersion = "15.0"; + +export function buildCachedFixtureApp({ + root, + tempRoot, + bundleId, + urlScheme, + log = () => {}, +}) { + const targetArch = process.arch === "arm64" ? "arm64" : "x86_64"; + const sdkVersion = commandOutput("xcrun", [ + "--sdk", + "iphonesimulator", + "--show-sdk-version", + ]); + const swiftVersion = commandOutput("xcrun", [ + "--sdk", + "iphonesimulator", + "swiftc", + "--version", + ]); + const plist = fixtureInfoPlist(bundleId, urlScheme); + const source = fixtureSwiftSource(); + const fingerprint = crypto + .createHash("sha256") + .update( + JSON.stringify({ targetArch, sdkVersion, swiftVersion, plist, source }), + ) + .digest("hex") + .slice(0, 16); + const cacheRoot = path.join( + root, + ".cache", + "simdeck", + "fixture", + `${targetArch}-iphonesimulator-${fingerprint}`, + ); + const cachedAppPath = path.join(cacheRoot, `${executable}.app`); + const appPath = path.join(tempRoot, `${executable}.app`); + + if (!isUsableApp(cachedAppPath)) { + log(`building cached SwiftUI fixture ${fingerprint}`); + buildFixtureIntoCache({ + cacheRoot, + cachedAppPath, + plist, + source, + targetArch, + }); + } else { + log(`using cached SwiftUI fixture ${fingerprint}`); + } + + fs.rmSync(appPath, { recursive: true, force: true }); + fs.cpSync(cachedAppPath, appPath, { recursive: true }); + return { appPath }; +} + +function buildFixtureIntoCache({ + cacheRoot, + cachedAppPath, + plist, + source, + targetArch, +}) { + const stagingRoot = `${cacheRoot}.tmp-${process.pid}-${Date.now()}`; + const stagingApp = path.join(stagingRoot, `${executable}.app`); + fs.rmSync(stagingRoot, { recursive: true, force: true }); + fs.mkdirSync(stagingApp, { recursive: true }); + + const plistPath = path.join(stagingApp, "Info.plist"); + const sourcePath = path.join(stagingRoot, `${executable}.swift`); + fs.writeFileSync(plistPath, plist); + fs.writeFileSync(sourcePath, source); + + run("xcrun", [ + "--sdk", + "iphonesimulator", + "swiftc", + "-target", + `${targetArch}-apple-ios${minimumIosVersion}-simulator`, + "-parse-as-library", + "-Onone", + "-framework", + "SwiftUI", + "-framework", + "UIKit", + sourcePath, + "-o", + path.join(stagingApp, executable), + ]); + + fs.rmSync(cacheRoot, { recursive: true, force: true }); + fs.mkdirSync(path.dirname(cacheRoot), { recursive: true }); + fs.renameSync(stagingRoot, cacheRoot); + + if (!isUsableApp(cachedAppPath)) { + throw new Error(`Cached fixture app was not created at ${cachedAppPath}`); + } +} + +function fixtureInfoPlist(bundleId, urlScheme) { + return ` + + + + CFBundleDevelopmentRegionen + CFBundleExecutable${executable} + CFBundleIdentifier${bundleId} + CFBundleInfoDictionaryVersion6.0 + CFBundleName${executable} + CFBundlePackageTypeAPPL + CFBundleShortVersionString1.0 + CFBundleVersion1 + LSRequiresIPhoneOS + MinimumOSVersion${minimumIosVersion} + UIDeviceFamily1 + CFBundleURLTypes + + + CFBundleURLName${executable} + CFBundleURLSchemes + ${urlScheme} + + + + +`; +} + +function fixtureSwiftSource() { + return `import SwiftUI + +struct FixtureView: View { + @State private var status = "Integration Ready" + @State private var tapCount = 0 + @State private var message = "" + @FocusState private var messageFocused: Bool + + var body: some View { + VStack(spacing: 24) { + Text("SimDeck Fixture") + .font(.title2) + .accessibilityIdentifier("fixture.title") + + Text(status) + .accessibilityIdentifier("fixture.status") + + Button("Continue") { + tapCount += 1 + status = "Continue Tapped \\(tapCount)" + } + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("fixture.continue") + + TextField("Message", text: $message) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("fixture.message") + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .focused($messageFocused) + .frame(width: 240) + } + .padding() + .onOpenURL { url in + if url.host == "focus-message" { + status = "Message Focused" + messageFocused = true + } else { + status = "URL Opened" + } + } + } +} + +@main +struct SimDeckFixtureApp: App { + var body: some Scene { + WindowGroup { + FixtureView() + } + } +} +`; +} + +function isUsableApp(appPath) { + const binary = path.join(appPath, executable); + return ( + fs.existsSync(path.join(appPath, "Info.plist")) && isExecutable(binary) + ); +} + +function isExecutable(filePath) { + try { + fs.accessSync(filePath, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +function commandOutput(command, args) { + return run(command, args).stdout.trim(); +} + +function run(command, args) { + const result = spawnSync(command, args, { + encoding: "utf8", + timeout: 300_000, + maxBuffer: 1024 * 1024 * 4, + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error( + `${command} ${args.join(" ")} failed with status ${result.status}: ${[ + result.stderr, + result.stdout, + ] + .filter(Boolean) + .join("\n")}`, + ); + } + return result; +} diff --git a/scripts/integration/js-api.mjs b/scripts/integration/js-api.mjs index 1cfc2a01..5f5806db 100644 --- a/scripts/integration/js-api.mjs +++ b/scripts/integration/js-api.mjs @@ -4,6 +4,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { connect } from "simdeck/test"; +import { buildCachedFixtureApp } from "./fixture.mjs"; const root = path.resolve(new URL("../..", import.meta.url).pathname); const simdeck = path.join(root, "build", "simdeck"); @@ -395,117 +396,13 @@ function compareRuntimeVersions(left, right) { } function buildFixtureApp() { - const appPath = path.join(tempRoot, "SimDeckFixture.app"); - fs.mkdirSync(appPath, { recursive: true }); - const executable = "SimDeckFixture"; - fs.writeFileSync( - path.join(appPath, "Info.plist"), - ` - - - - CFBundleDevelopmentRegionen - CFBundleExecutable${executable} - CFBundleIdentifier${fixtureBundleId} - CFBundleInfoDictionaryVersion6.0 - CFBundleNameSimDeckFixture - CFBundlePackageTypeAPPL - CFBundleShortVersionString1.0 - CFBundleVersion1 - LSRequiresIPhoneOS - MinimumOSVersion15.0 - UIDeviceFamily1 - CFBundleURLTypes - - - CFBundleURLNameSimDeckFixture - CFBundleURLSchemes - ${fixtureUrlScheme} - - - - -`, - ); - const main = path.join(tempRoot, "SimDeckFixture.swift"); - fs.writeFileSync( - main, - `import SwiftUI - -struct FixtureView: View { - @State private var status = "Integration Ready" - @State private var tapCount = 0 - @State private var message = "" - @FocusState private var messageFocused: Bool - - var body: some View { - VStack(spacing: 24) { - Text("SimDeck Fixture") - .font(.title2) - .accessibilityIdentifier("fixture.title") - - Text(status) - .accessibilityIdentifier("fixture.status") - - Button("Continue") { - tapCount += 1 - status = "Continue Tapped \\(tapCount)" - } - .buttonStyle(.borderedProminent) - .accessibilityIdentifier("fixture.continue") - - TextField("Message", text: $message) - .textFieldStyle(.roundedBorder) - .accessibilityIdentifier("fixture.message") - .textInputAutocapitalization(.never) - .autocorrectionDisabled(true) - .focused($messageFocused) - .frame(width: 240) - } - .padding() - .onOpenURL { url in - if url.host == "focus-message" { - status = "Message Focused" - messageFocused = true - } else { - status = "URL Opened" - } - } - } -} - -@main -struct SimDeckFixtureApp: App { - var body: some Scene { - WindowGroup { - FixtureView() - } - } -} -`, - ); - const targetArch = process.arch === "arm64" ? "arm64" : "x86_64"; - runText( - "xcrun", - [ - "--sdk", - "iphonesimulator", - "swiftc", - "-target", - `${targetArch}-apple-ios15.0-simulator`, - "-parse-as-library", - "-Onone", - "-framework", - "SwiftUI", - "-framework", - "UIKit", - main, - "-o", - path.join(appPath, executable), - ], - { timeoutMs: 300_000 }, - ); - return { appPath }; + return buildCachedFixtureApp({ + root, + tempRoot, + bundleId: fixtureBundleId, + urlScheme: fixtureUrlScheme, + log: (message) => console.log(`[fixture] ${message}`), + }); } function preapproveFixtureUrlScheme() { diff --git a/scripts/integration/prebuild-fixture.mjs b/scripts/integration/prebuild-fixture.mjs new file mode 100755 index 00000000..c23f3d12 --- /dev/null +++ b/scripts/integration/prebuild-fixture.mjs @@ -0,0 +1,23 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { buildCachedFixtureApp } from "./fixture.mjs"; + +const root = path.resolve(new URL("../..", import.meta.url).pathname); +const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "simdeck-fixture-prebuild-"), +); + +try { + const fixture = buildCachedFixtureApp({ + root, + tempRoot, + bundleId: "dev.nativescript.simdeck.integration.fixture", + urlScheme: "simdeck-fixture", + log: (message) => console.log(`[fixture] ${message}`), + }); + console.log(`Prepared ${fixture.appPath}`); +} finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); +} diff --git a/scripts/stress/simdeck.mjs b/scripts/stress/simdeck.mjs new file mode 100755 index 00000000..2bcb91b6 --- /dev/null +++ b/scripts/stress/simdeck.mjs @@ -0,0 +1,284 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; + +const args = parseArgs(process.argv.slice(2)); +const serverUrl = String( + args["server-url"] ?? + process.env.SIMDECK_STRESS_SERVER_URL ?? + "http://127.0.0.1:4310", +).replace(/\/$/, ""); +const iterations = positiveInt( + args.iterations ?? process.env.SIMDECK_STRESS_ITERATIONS, + 500, +); +const concurrency = positiveInt( + args.concurrency ?? process.env.SIMDECK_STRESS_CONCURRENCY, + 8, +); +const sampleEvery = positiveInt( + args["sample-every"] ?? process.env.SIMDECK_STRESS_SAMPLE_EVERY, + 25, +); +const maxRssMb = optionalNumber( + args["max-rss-mb"] ?? process.env.SIMDECK_STRESS_MAX_RSS_MB, +); +const maxRssGrowthMb = + optionalNumber( + args["max-rss-growth-mb"] ?? process.env.SIMDECK_STRESS_MAX_RSS_GROWTH_MB, + ) ?? 256; +const udid = args.udid ?? process.env.SIMDECK_STRESS_UDID; +const mutating = booleanArg( + args.mutating ?? process.env.SIMDECK_STRESS_MUTATING, +); +const pid = + optionalInt(args.pid ?? process.env.SIMDECK_STRESS_PID) ?? + discoverListenerPid(serverUrl); + +const samples = []; +const failures = []; +let completed = 0; +let nextIndex = 0; + +if (!pid) { + console.warn( + "Unable to discover SimDeck PID; RSS leak checks will be skipped. Pass --pid to enable them.", + ); +} else { + sampleRss("start"); +} + +await assertHealthy(); + +const startedAt = Date.now(); +await Promise.all( + Array.from({ length: concurrency }, async () => { + while (true) { + const index = nextIndex++; + if (index >= iterations) { + return; + } + try { + await runIteration(index); + } catch (error) { + failures.push({ + index, + error: error instanceof Error ? error.message : String(error), + }); + } finally { + completed += 1; + if (completed % sampleEvery === 0) { + sampleRss(`iteration-${completed}`); + } + } + } + }), +); + +sampleRss("end"); + +const elapsedSeconds = (Date.now() - startedAt) / 1000; +const firstRss = samples.find((sample) => sample.rssMb != null)?.rssMb; +const lastRss = [...samples] + .reverse() + .find((sample) => sample.rssMb != null)?.rssMb; +const peakRss = samples.reduce( + (peak, sample) => Math.max(peak, sample.rssMb ?? 0), + 0, +); +const rssGrowth = + firstRss != null && lastRss != null ? lastRss - firstRss : null; + +const summary = { + ok: failures.length === 0, + serverUrl, + pid, + iterations, + concurrency, + completed, + failures: failures.slice(0, 10), + elapsedSeconds: Number(elapsedSeconds.toFixed(2)), + requestsPerSecond: Number( + (completed / Math.max(elapsedSeconds, 0.001)).toFixed(2), + ), + rss: { + startMb: firstRss, + endMb: lastRss, + peakMb: peakRss || null, + growthMb: rssGrowth == null ? null : Number(rssGrowth.toFixed(2)), + samples, + }, +}; + +if (maxRssMb != null && peakRss > maxRssMb) { + summary.ok = false; + failures.push({ + index: -1, + error: `Peak RSS ${peakRss.toFixed(2)} MB exceeded ${maxRssMb} MB`, + }); +} +if (rssGrowth != null && rssGrowth > maxRssGrowthMb) { + summary.ok = false; + failures.push({ + index: -1, + error: `RSS growth ${rssGrowth.toFixed(2)} MB exceeded ${maxRssGrowthMb} MB`, + }); +} +summary.failures = failures.slice(0, 10); + +console.log(JSON.stringify(summary, null, 2)); +if (!summary.ok) { + process.exit(1); +} + +async function runIteration(index) { + const endpoints = [ + ["GET", "/api/health"], + ["GET", "/api/metrics"], + ["GET", "/api/simulators"], + ["GET", "/api/stream-quality"], + ]; + if (udid) { + endpoints.push(["GET", `/api/simulators/${encodeURIComponent(udid)}`]); + if (index % 5 === 0) { + endpoints.push([ + "POST", + `/api/simulators/${encodeURIComponent(udid)}/stream/refresh`, + {}, + ]); + } + if (mutating && index % 10 === 0) { + endpoints.push([ + "POST", + `/api/simulators/${encodeURIComponent(udid)}/touch`, + { x: 0.5, y: 0.5, phase: "moved" }, + ]); + } + } + + const [method, path, body] = endpoints[index % endpoints.length]; + await request(method, path, body); +} + +async function assertHealthy() { + const health = await request("GET", "/api/health"); + if (health?.ok !== true) { + throw new Error("SimDeck health endpoint did not return ok=true"); + } +} + +async function request(method, path, body) { + const response = await fetch(`${serverUrl}${path}`, { + method, + headers: body ? { "content-type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await response.text(); + if (!response.ok) { + throw new Error( + `${method} ${path} failed with ${response.status}: ${text.slice(0, 500)}`, + ); + } + if (!text) { + return null; + } + try { + return JSON.parse(text); + } catch { + return text; + } +} + +function sampleRss(label) { + if (!pid) { + return; + } + const rssKb = rssKbForPid(pid); + if (rssKb == null) { + failures.push({ index: -1, error: `Unable to sample RSS for pid ${pid}` }); + return; + } + samples.push({ + label, + completed, + rssMb: Number((rssKb / 1024).toFixed(2)), + }); +} + +function discoverListenerPid(url) { + const port = + new URL(url).port || (new URL(url).protocol === "https:" ? "443" : "80"); + try { + const output = execFileSync( + "lsof", + ["-nP", "-ti", `tcp:${port}`, "-sTCP:LISTEN"], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }, + ); + return optionalInt(output.trim().split(/\s+/)[0]); + } catch { + return null; + } +} + +function rssKbForPid(value) { + try { + const output = execFileSync("ps", ["-o", "rss=", "-p", String(value)], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + return optionalInt(output); + } catch { + return null; + } +} + +function parseArgs(values) { + const parsed = {}; + for (let index = 0; index < values.length; index += 1) { + const value = values[index]; + if (!value.startsWith("--")) { + continue; + } + const [rawKey, inlineValue] = value.slice(2).split("=", 2); + if (inlineValue != null) { + parsed[rawKey] = inlineValue; + } else if (values[index + 1] && !values[index + 1].startsWith("--")) { + parsed[rawKey] = values[index + 1]; + index += 1; + } else { + parsed[rawKey] = "true"; + } + } + return parsed; +} + +function positiveInt(value, fallback) { + const parsed = optionalInt(value); + return parsed && parsed > 0 ? parsed : fallback; +} + +function optionalInt(value) { + if (value == null || value === "") { + return null; + } + const parsed = Number.parseInt(String(value), 10); + return Number.isFinite(parsed) ? parsed : null; +} + +function optionalNumber(value) { + if (value == null || value === "") { + return null; + } + const parsed = Number.parseFloat(String(value)); + return Number.isFinite(parsed) ? parsed : null; +} + +function booleanArg(value) { + return ["1", "true", "yes", "on"].includes( + String(value ?? "") + .trim() + .toLowerCase(), + ); +} diff --git a/server/src/api/routes.rs b/server/src/api/routes.rs index a0344cf2..fc2f882d 100644 --- a/server/src/api/routes.rs +++ b/server/src/api/routes.rs @@ -3330,3 +3330,247 @@ async fn simulator_payload(state: AppState, udid: String) -> Result, .ok_or_else(|| AppError::not_found(format!("Unknown simulator {udid}")))?; Ok(json(json_value!({ "simulator": simulator }))) } + +#[cfg(test)] +mod tests { + use super::{ + attach_tree_metadata, available_sources_for_snapshot, best_inspector_session, + compact_accessibility_snapshot, element_matches_selector, first_matching_element, + inspector_available_sources, normalize_inspector_node, + normalize_screen_point_from_snapshot, normalized_gesture_coordinates, + parse_lsof_tcp_listener, split_filter_values, suppress_native_ax_translation_error, + tap_point_from_snapshot, trim_tree_depth, AccessibilityHierarchySource, + ElementSelectorPayload, InspectorSession, InspectorSessionTransport, SOURCE_NATIVE_AX, + SOURCE_NATIVE_SCRIPT, SOURCE_REACT_NATIVE, SOURCE_SWIFTUI, SOURCE_UIKIT, + }; + use serde_json::{json, Value}; + + fn selector() -> ElementSelectorPayload { + ElementSelectorPayload { + id: Some("continue-button".to_owned()), + label: Some("Continue".to_owned()), + value: None, + element_type: Some("Button".to_owned()), + } + } + + fn accessibility_snapshot() -> Value { + json!({ + "roots": [{ + "type": "Window", + "frame": { "x": 0.0, "y": 0.0, "width": 400.0, "height": 800.0 }, + "children": [{ + "type": "Button", + "AXIdentifier": "continue-button", + "AXLabel": "Continue", + "frame": { "x": 100.0, "y": 200.0, "width": 80.0, "height": 40.0 }, + "children": [] + }] + }] + }) + } + + #[test] + fn selector_matching_uses_identifier_label_and_type_aliases() { + let snapshot = accessibility_snapshot(); + let node = &snapshot["roots"][0]["children"][0]; + + assert!(element_matches_selector(node, &selector())); + assert!(!element_matches_selector( + node, + &ElementSelectorPayload { + label: Some("Cancel".to_owned()), + ..selector() + } + )); + } + + #[test] + fn first_matching_element_searches_descendants() { + let found = first_matching_element(&accessibility_snapshot(), &selector()).unwrap(); + + assert_eq!(found["AXIdentifier"], "continue-button"); + } + + #[test] + fn tap_point_from_snapshot_returns_normalized_element_center() { + let point = tap_point_from_snapshot(&accessibility_snapshot(), &selector()).unwrap(); + + assert_eq!(point, (0.35, 0.275)); + } + + #[test] + fn normalize_screen_point_clamps_to_root_bounds() { + let point = + normalize_screen_point_from_snapshot(&accessibility_snapshot(), 500.0, -20.0).unwrap(); + + assert_eq!(point, (1.0, 0.0)); + } + + #[test] + fn gesture_presets_clamp_delta_and_reject_unknown_names() { + assert_eq!( + normalized_gesture_coordinates("scroll-down", Some(2.0)).unwrap(), + (0.5, 0.975, 0.5, 0.025000000000000022, 500) + ); + assert!(normalized_gesture_coordinates("orbit", None).is_err()); + } + + #[test] + fn compact_accessibility_snapshot_removes_nested_noise_but_keeps_identity() { + let compact = compact_accessibility_snapshot(&accessibility_snapshot()); + + assert_eq!(compact["roots"][0]["children"][0]["id"], "continue-button"); + assert_eq!(compact["roots"][0]["children"][0]["label"], "Continue"); + assert!(compact["roots"][0]["children"][0].get("frame").is_some()); + } + + #[test] + fn trim_tree_depth_drops_children_at_requested_depth() { + let trimmed = trim_tree_depth(accessibility_snapshot(), Some(0)); + + assert_eq!(trimmed["roots"][0]["children"].as_array().unwrap().len(), 0); + } + + #[test] + fn inspector_source_detection_prefers_framework_specific_sources() { + let sources = inspector_available_sources(&json!({ + "reactNative": { "available": true }, + "appHierarchy": { "available": true, "source": "nativescript" }, + "uikit": { "available": true } + })); + + assert_eq!( + sources, + vec![ + SOURCE_REACT_NATIVE.to_owned(), + SOURCE_NATIVE_SCRIPT.to_owned(), + SOURCE_UIKIT.to_owned() + ] + ); + } + + #[test] + fn best_inspector_session_prioritizes_react_native_then_nativescript() { + let uikit = InspectorSession { + transport: InspectorSessionTransport::Connected, + available_sources: vec![SOURCE_UIKIT.to_owned()], + info: json!({}), + process_identifier: 1, + }; + let react_native = InspectorSession { + transport: InspectorSessionTransport::Tcp { port: 47370 }, + available_sources: vec![SOURCE_REACT_NATIVE.to_owned()], + info: json!({}), + process_identifier: 2, + }; + + let best = best_inspector_session(vec![uikit, react_native]).unwrap(); + + assert_eq!(best.process_identifier, 2); + } + + #[test] + fn available_sources_for_react_native_snapshot_removes_uikit_fallback() { + let sources = available_sources_for_snapshot( + &[SOURCE_UIKIT.to_owned(), SOURCE_NATIVE_AX.to_owned()], + &json!({ "source": SOURCE_REACT_NATIVE }), + ); + + assert_eq!( + sources, + vec![SOURCE_REACT_NATIVE.to_owned(), SOURCE_NATIVE_AX.to_owned()] + ); + } + + #[test] + fn native_ax_expected_translation_failures_are_suppressed() { + assert_eq!( + suppress_native_ax_translation_error( + "No translation object returned for simulator SIM" + ), + None + ); + assert!(suppress_native_ax_translation_error("Bridge failed").is_some()); + } + + #[test] + fn parse_lsof_tcp_listener_extracts_pid_and_port() { + assert_eq!( + parse_lsof_tcp_listener("Fixture 123 dj 12u IPv4 0x1 0t0 TCP 127.0.0.1:47370 (LISTEN)"), + Some((123, 47370)) + ); + assert_eq!( + parse_lsof_tcp_listener( + "Fixture 123 dj 12u IPv4 0x1 0t0 TCP 127.0.0.1:47370 (ESTABLISHED)" + ), + None + ); + } + + #[test] + fn normalize_inspector_node_maps_runtime_metadata_to_accessibility_fields() { + let normalized = normalize_inspector_node( + &json!({ + "id": "node-1", + "className": "UIButton", + "displayName": "Button", + "accessibility": { + "identifier": "continue-button", + "label": "Continue", + "value": "Ready" + }, + "frameInScreen": { "x": 10.0, "y": 20.0, "width": 30.0, "height": 40.0 }, + "isUserInteractionEnabled": true, + "isHidden": false, + "alpha": 1.0, + "children": [] + }), + Some(42), + ); + + assert_eq!(normalized["AXUniqueId"], "node-1"); + assert_eq!(normalized["AXIdentifier"], "continue-button"); + assert_eq!(normalized["AXLabel"], "Continue"); + assert_eq!(normalized["AXValue"], "Ready"); + assert_eq!(normalized["enabled"], true); + assert_eq!(normalized["pid"], 42); + } + + #[test] + fn tree_metadata_attaches_available_sources_and_fallback_reason() { + let metadata = attach_tree_metadata( + json!({ "roots": [], "source": SOURCE_NATIVE_AX }), + &[SOURCE_SWIFTUI.to_owned(), SOURCE_NATIVE_AX.to_owned()], + Some("native accessibility unavailable".to_owned()), + ); + + assert_eq!(metadata["availableSources"][0], SOURCE_SWIFTUI); + assert_eq!(metadata["fallbackSource"], SOURCE_NATIVE_AX); + assert_eq!( + metadata["fallbackReason"], + "native accessibility unavailable" + ); + } + + #[test] + fn accessibility_source_parser_accepts_documented_aliases() { + assert!(matches!( + AccessibilityHierarchySource::parse(Some("rn")).unwrap(), + AccessibilityHierarchySource::ReactNative + )); + assert!(matches!( + AccessibilityHierarchySource::parse(Some("swift-ui")).unwrap(), + AccessibilityHierarchySource::SwiftUI + )); + assert!(AccessibilityHierarchySource::parse(Some("unknown")).is_err()); + } + + #[test] + fn split_filter_values_trims_lowercases_and_omits_empty_parts() { + assert_eq!( + split_filter_values(Some(" Error, SpringBoard ,, DEBUG ")), + vec!["error", "springboard", "debug"] + ); + } +} diff --git a/server/src/config.rs b/server/src/config.rs index e70afd03..b9638e3a 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -46,3 +46,62 @@ impl Config { SocketAddr::new(self.bind_ip, self.http_port) } } + +#[cfg(test)] +mod tests { + use super::Config; + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + use std::path::PathBuf; + + fn config(bind_ip: IpAddr, advertise_host: Option) -> Config { + Config::new( + 4310, + PathBuf::from("client/dist"), + bind_ip, + advertise_host, + "h264-software".to_owned(), + false, + Some("token".to_owned()), + None, + ) + } + + #[test] + fn unspecified_bind_defaults_advertise_host_to_loopback() { + assert_eq!( + config(IpAddr::V4(Ipv4Addr::UNSPECIFIED), None).advertise_host, + "127.0.0.1" + ); + assert_eq!( + config(IpAddr::V6(Ipv6Addr::UNSPECIFIED), None).advertise_host, + "127.0.0.1" + ); + } + + #[test] + fn explicit_advertise_host_overrides_bind_address() { + assert_eq!( + config( + IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), + Some("192.168.1.50".to_owned()) + ) + .advertise_host, + "192.168.1.50" + ); + } + + #[test] + fn concrete_bind_address_is_used_as_advertise_host() { + assert_eq!( + config(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 23)), None).advertise_host, + "192.168.1.23" + ); + } + + #[test] + fn http_addr_uses_configured_bind_ip_and_port() { + let config = config(IpAddr::V4(Ipv4Addr::LOCALHOST), None); + + assert_eq!(config.http_addr().to_string(), "127.0.0.1:4310"); + } +} diff --git a/server/src/metrics/counters.rs b/server/src/metrics/counters.rs index 8e702e1d..7c0f422b 100644 --- a/server/src/metrics/counters.rs +++ b/server/src/metrics/counters.rs @@ -152,3 +152,114 @@ fn current_time_ms() -> f64 { .map(|duration| duration.as_millis() as f64) .unwrap_or(0.0) } + +#[cfg(test)] +mod tests { + use super::{current_time_ms, ClientStreamStats, Metrics}; + + fn stats(client_id: &str, kind: &str, timestamp_ms: Option) -> ClientStreamStats { + ClientStreamStats { + client_id: client_id.to_owned(), + kind: kind.to_owned(), + timestamp_ms, + udid: None, + connection_id: None, + status: None, + detail: None, + error: None, + ice_connection_state: None, + peer_connection_state: None, + ice_gathering_state: None, + signaling_state: None, + local_candidate_summary: None, + remote_candidate_summary: None, + selected_candidate_pair: None, + url: None, + user_agent: None, + visibility_state: None, + focused: None, + codec: None, + width: None, + height: None, + received_packets: None, + decoded_frames: None, + rendered_frames: None, + dropped_frames: None, + reconnects: None, + frame_sequence: None, + decode_queue_size: None, + waiting_for_key_frame: None, + packet_fps: None, + decoded_fps: None, + dropped_fps: None, + page_fps: None, + app_fps: None, + latest_render_ms: None, + max_render_ms: None, + average_render_ms: None, + latest_frame_gap_ms: None, + } + } + + #[test] + fn client_stream_stats_replace_matching_client_and_kind() { + let metrics = Metrics::default(); + let now = current_time_ms(); + let mut first = stats("client-1", "webrtc", Some(now)); + first.status = Some("connecting".to_owned()); + let mut second = stats("client-1", "webrtc", Some(now + 1.0)); + second.status = Some("connected".to_owned()); + + metrics.record_client_stream_stats(first); + metrics.record_client_stream_stats(second); + + let snapshots = metrics.client_stream_stats_snapshot(); + assert_eq!(snapshots.len(), 1); + assert_eq!(snapshots[0].status.as_deref(), Some("connected")); + } + + #[test] + fn client_stream_stats_keep_distinct_kinds_for_same_client() { + let metrics = Metrics::default(); + let now = current_time_ms(); + + metrics.record_client_stream_stats(stats("client-1", "webrtc", Some(now))); + metrics.record_client_stream_stats(stats("client-1", "worker", Some(now))); + + let snapshots = metrics.client_stream_stats_snapshot(); + assert_eq!(snapshots.len(), 2); + } + + #[test] + fn client_stream_stats_prune_missing_and_stale_timestamps() { + let metrics = Metrics::default(); + let now = current_time_ms(); + + metrics.record_client_stream_stats(stats("missing", "webrtc", None)); + metrics.record_client_stream_stats(stats("stale", "webrtc", Some(now - 20_000.0))); + metrics.record_client_stream_stats(stats("fresh", "webrtc", Some(now))); + + let snapshots = metrics.client_stream_stats_snapshot(); + assert_eq!(snapshots.len(), 1); + assert_eq!(snapshots[0].client_id, "fresh"); + } + + #[test] + fn client_stream_stats_keep_latest_limit() { + let metrics = Metrics::default(); + let now = current_time_ms(); + + for index in 0..60 { + metrics.record_client_stream_stats(stats( + &format!("client-{index}"), + "webrtc", + Some(now + index as f64), + )); + } + + let snapshots = metrics.client_stream_stats_snapshot(); + assert_eq!(snapshots.len(), 48); + assert_eq!(snapshots[0].client_id, "client-12"); + assert_eq!(snapshots[47].client_id, "client-59"); + } +} diff --git a/server/src/native/bridge.rs b/server/src/native/bridge.rs index b36bcd0d..6318a754 100644 --- a/server/src/native/bridge.rs +++ b/server/src/native/bridge.rs @@ -766,3 +766,97 @@ fn schedule_recoverable_restart_if_needed(message: &str) { std::process::exit(RECOVERABLE_RESTART_EXIT_CODE); }); } + +#[cfg(test)] +mod tests { + use super::{ + is_core_simulator_service_mismatch, log_entry_matches, LogEntry, LogFilters, Simulator, + }; + use serde_json::json; + + fn simulator_json(is_booted: serde_json::Value, is_available: serde_json::Value) -> String { + json!({ + "udid": "SIM-1", + "name": "iPhone Test", + "state": "Booted", + "isBooted": is_booted, + "isAvailable": is_available, + "lastBootedAt": null, + "dataPath": null, + "logPath": null, + "deviceTypeIdentifier": null, + "deviceTypeName": "iPhone", + "runtimeIdentifier": null, + "runtimeName": "iOS" + }) + .to_string() + } + + fn log_entry(level: &str, process: &str, message: &str) -> LogEntry { + LogEntry { + timestamp: "2026-05-01T00:00:00Z".to_owned(), + level: level.to_owned(), + process: process.to_owned(), + pid: json!(123), + subsystem: "com.simdeck.test".to_owned(), + category: "unit".to_owned(), + message: message.to_owned(), + } + } + + #[test] + fn simulator_boolish_fields_accept_native_json_variants() { + let true_bool: Simulator = + serde_json::from_str(&simulator_json(json!(true), json!(false))).unwrap(); + let numeric: Simulator = serde_json::from_str(&simulator_json(json!(1), json!(0))).unwrap(); + let string: Simulator = + serde_json::from_str(&simulator_json(json!("TRUE"), json!("false"))).unwrap(); + + assert!(true_bool.is_booted); + assert!(!true_bool.is_available); + assert!(numeric.is_booted); + assert!(!numeric.is_available); + assert!(string.is_booted); + assert!(!string.is_available); + } + + #[test] + fn simulator_boolish_fields_reject_ambiguous_values() { + let result = serde_json::from_str::(&simulator_json(json!(2), json!(true))); + + assert!(result.is_err()); + } + + #[test] + fn log_filters_match_error_fault_aliases_and_query_text() { + let entry = log_entry("Fault", "SpringBoard", "launch failed for fixture"); + let filters = LogFilters::new( + vec!["error".to_owned()], + vec!["springboard".to_owned()], + "fixture".to_owned(), + ); + + assert!(log_entry_matches(&entry, &filters)); + } + + #[test] + fn log_filters_reject_non_matching_processes() { + let entry = log_entry("Default", "backboardd", "touch delivered"); + let filters = LogFilters::new(vec![], vec!["SpringBoard".to_owned()], String::new()); + + assert!(!log_entry_matches(&entry, &filters)); + } + + #[test] + fn core_simulator_mismatch_detection_covers_known_failure_strings() { + assert!(is_core_simulator_service_mismatch( + "CoreSimulator.framework was changed while the process was running" + )); + assert!(is_core_simulator_service_mismatch( + "Service version 987 does not match expected service version 654" + )); + assert!(!is_core_simulator_service_mismatch( + "Unable to initialize the private simulator display bridge." + )); + } +} diff --git a/server/src/transport/webrtc.rs b/server/src/transport/webrtc.rs index 9ed96e78..a6d24536 100644 --- a/server/src/transport/webrtc.rs +++ b/server/src/transport/webrtc.rs @@ -1074,9 +1074,16 @@ impl Drop for WebRtcMetricsGuard { #[cfg(test)] mod tests { use super::{ - append_avcc_parameter_sets, append_length_prefixed_nalus, h264_sdp_fmtp_line, is_annex_b, - is_h264_codec, ANNEX_B_START_CODE, + adaptive_interval_for_write, append_avcc_parameter_sets, append_length_prefixed_nalus, + h264_annex_b_sample, h264_sdp_fmtp_line, is_annex_b, is_h264_codec, realtime_packet_pacing, + WebRtcMetricsGuard, WebRtcSendTiming, ANNEX_B_START_CODE, }; + use crate::metrics::counters::Metrics; + use crate::transport::packet::FramePacket; + use bytes::Bytes; + use std::sync::atomic::Ordering; + use std::sync::Arc; + use std::time::Duration; #[test] fn accepts_browser_h264_codec_strings() { @@ -1134,6 +1141,137 @@ mod tests { assert!(!super::has_media_stream(&udid)); } + #[test] + fn metrics_guard_balances_stream_connect_and_disconnect_counts() { + let metrics = Arc::new(Metrics::default()); + + { + let _guard = WebRtcMetricsGuard::new(metrics.clone()); + assert_eq!(metrics.subscribers_connected.load(Ordering::Relaxed), 1); + assert_eq!(metrics.active_streams.load(Ordering::Relaxed), 1); + } + + assert_eq!(metrics.subscribers_disconnected.load(Ordering::Relaxed), 1); + assert_eq!(metrics.active_streams.load(Ordering::Relaxed), 0); + } + + #[test] + fn metrics_guard_does_not_underflow_active_streams() { + let metrics = Arc::new(Metrics::default()); + let guard = WebRtcMetricsGuard::new(metrics.clone()); + metrics.active_streams.store(0, Ordering::Relaxed); + + drop(guard); + + assert_eq!(metrics.active_streams.load(Ordering::Relaxed), 0); + assert_eq!(metrics.subscribers_disconnected.load(Ordering::Relaxed), 1); + } + + #[test] + fn send_timing_uses_frame_timestamps_for_non_realtime_streams() { + let mut timing = WebRtcSendTiming::new(); + let first = FramePacket { + frame_sequence: 1, + timestamp_us: 10_000, + is_keyframe: true, + width: 100, + height: 100, + codec: Some("h264".to_owned()), + description: None, + data: Bytes::from_static(&[0, 0, 1, 0x65]), + }; + let second = FramePacket { + frame_sequence: 2, + timestamp_us: 43_333, + is_keyframe: false, + width: 100, + height: 100, + codec: Some("h264".to_owned()), + description: None, + data: Bytes::from_static(&[0, 0, 1, 0x41]), + }; + + assert_eq!( + timing.duration_for(&first, false), + Duration::from_micros(16_667) + ); + assert_eq!( + timing.duration_for(&second, false), + Duration::from_micros(33_333) + ); + } + + #[test] + fn send_timing_clamps_non_realtime_timestamp_gaps() { + let mut timing = WebRtcSendTiming::new(); + let first = FramePacket { + frame_sequence: 1, + timestamp_us: 100_000, + is_keyframe: true, + width: 100, + height: 100, + codec: Some("h264".to_owned()), + description: None, + data: Bytes::from_static(&[0, 0, 1, 0x65]), + }; + let backwards = FramePacket { + timestamp_us: 90_000, + ..first.clone_for_test(2) + }; + let huge_gap = FramePacket { + timestamp_us: 1_000_000, + ..first.clone_for_test(3) + }; + + assert_eq!( + timing.duration_for(&first, false), + Duration::from_micros(16_667) + ); + assert_eq!( + timing.duration_for(&backwards, false), + Duration::from_micros(16_667) + ); + assert_eq!( + timing.duration_for(&huge_gap, false), + Duration::from_micros(100_000) + ); + } + + #[test] + fn adaptive_refresh_interval_tracks_write_latency_with_bounds() { + let floor = Duration::from_millis(16); + let ceiling = Duration::from_millis(100); + + assert_eq!( + adaptive_interval_for_write(Duration::from_millis(1), floor, ceiling), + floor + ); + assert_eq!( + adaptive_interval_for_write(Duration::from_millis(30), floor, ceiling), + Duration::from_millis(60) + ); + assert_eq!( + adaptive_interval_for_write(Duration::from_millis(500), floor, ceiling), + ceiling + ); + } + + #[test] + fn realtime_packet_pacing_batches_large_frames() { + assert_eq!( + realtime_packet_pacing(Duration::from_millis(20), 10, true), + Some((2, Duration::from_millis(4))) + ); + assert_eq!( + realtime_packet_pacing(Duration::from_millis(20), 10, false), + None + ); + assert_eq!( + realtime_packet_pacing(Duration::from_millis(20), 1, true), + None + ); + } + #[test] fn converts_avcc_parameter_sets_to_annex_b() { let avcc = [ @@ -1174,4 +1312,73 @@ mod tests { ); assert!(is_annex_b(&output)); } + + #[test] + fn rejects_truncated_h264_decoder_config_records() { + let mut output = Vec::new(); + + let result = + append_avcc_parameter_sets(&[1, 0x42, 0xe0, 0x1f, 0xff, 0xe1, 0, 4, 0x67], &mut output); + + assert!(result.is_err()); + } + + #[test] + fn rejects_truncated_length_prefixed_h264_samples() { + let mut output = Vec::new(); + + let result = append_length_prefixed_nalus(&[0, 0, 0, 4, 0x65], 4, &mut output); + + assert!(result.is_err()); + } + + #[test] + fn keyframes_include_decoder_config_before_sample_nalus() { + let frame = FramePacket { + frame_sequence: 1, + timestamp_us: 0, + is_keyframe: true, + width: 100, + height: 100, + codec: Some("avc1.42e01f".to_owned()), + description: Some(Bytes::from_static(&[ + 1, 0x42, 0xe0, 0x1f, 0xff, 0xe1, 0, 3, 0x67, 0x42, 0x00, 1, 0, 2, 0x68, 0xce, + ])), + data: Bytes::from_static(&[0, 0, 0, 2, 0x65, 0x88]), + }; + + let sample = h264_annex_b_sample(&frame).unwrap(); + + assert_eq!( + sample, + [ + ANNEX_B_START_CODE, + &[0x67, 0x42, 0x00], + ANNEX_B_START_CODE, + &[0x68, 0xce], + ANNEX_B_START_CODE, + &[0x65, 0x88], + ] + .concat() + ); + } + + trait CloneFrameForTest { + fn clone_for_test(&self, frame_sequence: u64) -> Self; + } + + impl CloneFrameForTest for FramePacket { + fn clone_for_test(&self, frame_sequence: u64) -> Self { + Self { + frame_sequence, + timestamp_us: self.timestamp_us, + is_keyframe: self.is_keyframe, + width: self.width, + height: self.height, + codec: self.codec.clone(), + description: self.description.clone(), + data: self.data.clone(), + } + } + } } From 5bbf2efa220745f0e51b46e26f6bfb7a9d2a37ba Mon Sep 17 00:00:00 2001 From: DjDeveloperr Date: Fri, 1 May 2026 16:52:43 -0400 Subject: [PATCH 2/3] Fix studio provider expose URL normalization --- .github/workflows/ci.yml | 3 + package.json | 1 + scripts/studio-provider-bridge.mjs | 123 +++++++++++++++--------- scripts/studio-provider-bridge.test.mjs | 43 +++++++++ 4 files changed, 126 insertions(+), 44 deletions(-) create mode 100644 scripts/studio-provider-bridge.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e1c0a07..5325b3ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,6 +69,9 @@ jobs: - name: Check Prettier formatting run: npx prettier --check . + - name: Test studio provider bridge + run: npm run test:studio-provider + - name: Typecheck client run: npm run --prefix client typecheck diff --git a/package.json b/package.json index 4bea9ea5..63c4c1b5 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "test:integration:fixture": "node scripts/integration/prebuild-fixture.mjs", "test:integration:js-api": "node scripts/integration/js-api.mjs", "test:integration:js-api:verbose": "SIMDECK_INTEGRATION_SHOW_SIMULATOR=1 node scripts/integration/js-api.mjs", + "test:studio-provider": "node --test scripts/studio-provider-bridge.test.mjs", "test:stress": "node scripts/stress/simdeck.mjs", "ci": "npm run lint && npm run build:all && npm run test && npm run package:vscode-extension", "dev": "npm run build:cli && node scripts/dev.mjs", diff --git a/scripts/studio-provider-bridge.mjs b/scripts/studio-provider-bridge.mjs index 14f66007..2233ea9a 100644 --- a/scripts/studio-provider-bridge.mjs +++ b/scripts/studio-provider-bridge.mjs @@ -2,6 +2,7 @@ import crypto from "node:crypto"; import os from "node:os"; +import { pathToFileURL } from "node:url"; const cloudUrl = ( process.env.SIMDECK_CLOUD_URL || "https://simdeck.djdev.me" @@ -22,58 +23,62 @@ const providerId = let stopped = false; let lastRegisterAt = 0; let registered = false; -for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) { - process.once(signal, () => { - stopped = true; - }); -} -try { - if (!previewId || !providerToken) { - const session = await createLocalProviderSession(); - previewId = session.sessionId; - providerToken = session.providerToken; - publicUrl = session.url; +if (isMainModule()) { + for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) { + process.once(signal, () => { + stopped = true; + }); } - if (!publicUrl) { - publicUrl = `${cloudUrl}/simulator/${encodeURIComponent(previewId)}`; - } - if (!localToken) { - localToken = providerToken; - } + try { + if (!previewId || !providerToken) { + const session = await createLocalProviderSession(); + previewId = session.sessionId; + providerToken = session.providerToken; + publicUrl = session.url; + } - console.log(`[simdeck-provider-bridge] ${publicUrl}`); + if (!publicUrl) { + publicUrl = `${cloudUrl}/simulator/${encodeURIComponent(previewId)}`; + } + publicUrl = normalizeStudioPublicUrl(publicUrl); + if (!localToken) { + localToken = providerToken; + } - await registerProvider(); + console.log(`[simdeck-provider-bridge] ${publicUrl}`); - while (!stopped) { - try { - if (Date.now() - lastRegisterAt > registerIntervalMs) { - await registerProvider(); - } - const next = await fetchJson( - `${cloudUrl}/api/actions/providers/rpc/next`, - { - previewId, - providerToken, - }, - ); - if (!next || !next.request) { - await sleep(250); - continue; + await registerProvider(); + + while (!stopped) { + try { + if (Date.now() - lastRegisterAt > registerIntervalMs) { + await registerProvider(); + } + const next = await fetchJson( + `${cloudUrl}/api/actions/providers/rpc/next`, + { + previewId, + providerToken, + }, + ); + if (!next || !next.request) { + await sleep(250); + continue; + } + await handleRequest(next.request); + } catch (error) { + console.error( + `[simdeck-provider-bridge] ${error instanceof Error ? error.message : String(error)}`, + ); + await sleep(1000); } - await handleRequest(next.request); - } catch (error) { - console.error( - `[simdeck-provider-bridge] ${error instanceof Error ? error.message : String(error)}`, - ); - await sleep(1000); } - } -} finally { - if (registered) { - await markProviderExpired(); + } finally { + if (registered) { + await markProviderExpired(); + } } } @@ -233,6 +238,36 @@ function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } +function normalizeStudioPublicUrl(value) { + return normalizeStudioPublicUrlWithCloud(value, cloudUrl); +} + +export function normalizeStudioPublicUrlWithCloud(value, baseCloudUrl) { + const trimmed = String(value || "").trim(); + if (!trimmed) { + return ""; + } + + const normalizedCloudUrl = baseCloudUrl.replace(/\/$/, ""); + const cloudOrigin = new URL(normalizedCloudUrl).origin; + const collapsed = trimmed + .replace(repeatedPrefixPattern(normalizedCloudUrl), normalizedCloudUrl) + .replace(repeatedPrefixPattern(cloudOrigin), cloudOrigin); + return new URL(collapsed, `${normalizedCloudUrl}/`).toString(); +} + +function repeatedPrefixPattern(prefix) { + return new RegExp(`^(?:${escapeRegExp(prefix)}){2,}`); +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function isMainModule() { + return import.meta.url === pathToFileURL(process.argv[1] || "").href; +} + function stableLocalProviderId() { const fingerprint = [ os.hostname(), diff --git a/scripts/studio-provider-bridge.test.mjs b/scripts/studio-provider-bridge.test.mjs new file mode 100644 index 00000000..927127fe --- /dev/null +++ b/scripts/studio-provider-bridge.test.mjs @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { normalizeStudioPublicUrlWithCloud } from "./studio-provider-bridge.mjs"; + +const cloudUrl = "https://simdeck.djdev.me"; + +test("normalizes relative studio expose paths against the cloud URL", () => { + assert.equal( + normalizeStudioPublicUrlWithCloud("/simulator/preview-123", cloudUrl), + "https://simdeck.djdev.me/simulator/preview-123", + ); +}); + +test("collapses duplicated full cloud URL prefixes", () => { + assert.equal( + normalizeStudioPublicUrlWithCloud( + "https://simdeck.djdev.mehttps://simdeck.djdev.me/simulator/preview-123", + cloudUrl, + ), + "https://simdeck.djdev.me/simulator/preview-123", + ); +}); + +test("collapses duplicated cloud origins when base URL has a path", () => { + assert.equal( + normalizeStudioPublicUrlWithCloud( + "https://simdeck.djdev.mehttps://simdeck.djdev.me/simulator/preview-123", + "https://simdeck.djdev.me/actions", + ), + "https://simdeck.djdev.me/simulator/preview-123", + ); +}); + +test("preserves valid external tunnel URLs", () => { + assert.equal( + normalizeStudioPublicUrlWithCloud( + "https://preview.example.test/simulator/preview-123", + cloudUrl, + ), + "https://preview.example.test/simulator/preview-123", + ); +}); From b0df48c4ea2c6d1aecbb9a8539fb07915af64ba7 Mon Sep 17 00:00:00 2001 From: DjDeveloperr Date: Fri, 1 May 2026 17:07:15 -0400 Subject: [PATCH 3/3] Support embedded client expose URLs --- client/src/api/client.ts | 4 +- client/src/api/config.ts | 31 +++++++++++ client/src/api/controls.ts | 3 +- client/src/app/AppShell.tsx | 54 ++++++++++++++++--- client/src/embedded.ts | 3 ++ .../src/features/simulators/SimulatorMenu.tsx | 50 +++++++++-------- .../src/features/stream/streamWorkerClient.ts | 40 ++++++++------ client/src/features/stream/useLiveStream.ts | 6 ++- client/src/features/toolbar/Toolbar.tsx | 3 ++ 9 files changed, 145 insertions(+), 49 deletions(-) create mode 100644 client/src/api/config.ts create mode 100644 client/src/embedded.ts diff --git a/client/src/api/client.ts b/client/src/api/client.ts index fea82558..29509a42 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -1,4 +1,4 @@ -import { API_ROOT } from "../shared/constants"; +import { apiUrl } from "./config"; import type { HealthResponse } from "./types"; export class ApiError extends Error { @@ -32,7 +32,7 @@ export async function apiRequest( options: RequestInit = {}, ): Promise { const { headers, ...rest } = options; - const response = await fetch(`${API_ROOT}${path}`, { + const response = await fetch(apiUrl(path), { ...rest, headers: apiHeaders(headers), }); diff --git a/client/src/api/config.ts b/client/src/api/config.ts new file mode 100644 index 00000000..0b5ffe3b --- /dev/null +++ b/client/src/api/config.ts @@ -0,0 +1,31 @@ +export interface SimDeckClientConfig { + apiRoot?: string; +} + +let clientConfig: Required = { + apiRoot: "", +}; + +export function configureSimDeckClient(config: SimDeckClientConfig): void { + clientConfig = { + ...clientConfig, + ...config, + apiRoot: normalizeRoot(config.apiRoot ?? clientConfig.apiRoot), + }; +} + +export function apiRoot(): string { + return clientConfig.apiRoot; +} + +export function apiUrl(path: string): string { + const root = apiRoot(); + if (!root) { + return path; + } + return `${root}${path.startsWith("/") ? path : `/${path}`}`; +} + +function normalizeRoot(root: string): string { + return root.replace(/\/+$/, ""); +} diff --git a/client/src/api/controls.ts b/client/src/api/controls.ts index c276caf3..f5d0459d 100644 --- a/client/src/api/controls.ts +++ b/client/src/api/controls.ts @@ -1,4 +1,5 @@ import { accessTokenFromLocation, apiRequest } from "./client"; +import { apiUrl } from "./config"; import type { KeyPayload, LaunchPayload, @@ -58,7 +59,7 @@ export function sendKey(udid: string, payload: KeyPayload) { export function simulatorControlSocketUrl(udid: string) { const url = new URL( - `/api/simulators/${encodeURIComponent(udid)}/control`, + apiUrl(`/api/simulators/${encodeURIComponent(udid)}/control`), window.location.href, ); const token = accessTokenFromLocation(); diff --git a/client/src/app/AppShell.tsx b/client/src/app/AppShell.tsx index 55ce8f79..395b678b 100644 --- a/client/src/app/AppShell.tsx +++ b/client/src/app/AppShell.tsx @@ -9,6 +9,7 @@ import { } from "react"; import { ApiError, accessTokenFromLocation, pairBrowser } from "../api/client"; +import { apiUrl, configureSimDeckClient } from "../api/config"; import { bootSimulator, dismissKeyboard, @@ -61,7 +62,6 @@ import { } from "../features/viewport/viewportMath"; import { DEVICE_SCREEN_WIDTH, - STREAM_ORIGIN, ZOOM_ANIMATION_MS, ZOOM_STEP, } from "../shared/constants"; @@ -102,7 +102,7 @@ function buildScreenMaskUrl(udid: string, stamp: number): string { } function buildAuthenticatedAssetUrl(path: string, stamp: number): string { - const url = new URL(path, `${STREAM_ORIGIN || window.location.origin}/`); + const url = new URL(apiUrl(path), window.location.href); url.searchParams.set("stamp", String(stamp)); const token = accessTokenFromLocation(); if (token) { @@ -172,10 +172,26 @@ type SimulatorTransition = { udid: string; }; -export function AppShell() { +export interface AppShellProps { + apiRoot?: string; + fixedSimulatorUDID?: string | null; + hideSimulatorSelection?: boolean; + pairingEnabled?: boolean; +} + +export function AppShell({ + apiRoot = "", + fixedSimulatorUDID = null, + hideSimulatorSelection = false, + pairingEnabled = true, +}: AppShellProps = {}) { + configureSimDeckClient({ apiRoot }); const [initialUiState] = useState(readPersistedUiState); const [initialSelectedUDID] = useState( - () => readDeviceQueryParam() ?? initialUiState.selectedUDID, + () => + fixedSimulatorUDID ?? + readDeviceQueryParam() ?? + initialUiState.selectedUDID, ); const initialViewportState = initialSelectedUDID ? viewportStateForUDID(initialUiState, initialSelectedUDID) @@ -301,6 +317,14 @@ export function AppShell() { }); const selectedSimulator = + (fixedSimulatorUDID + ? (simulators.find( + (simulator) => simulator.udid === fixedSimulatorUDID, + ) ?? + simulators.find((simulator) => + simulatorMatchesIdentifier(simulator, fixedSimulatorUDID), + )) + : null) ?? simulators.find((simulator) => simulator.udid === selectedUDID) ?? simulators.find((simulator) => simulatorMatchesIdentifier(simulator, selectedUDID), @@ -354,6 +378,7 @@ export function AppShell() { !selectedSimulator || !streamError || readDeviceQueryParam() || + fixedSimulatorUDID || !isStreamAttachFailure(streamError) ) { return; @@ -377,7 +402,13 @@ export function AppShell() { `${selectedSimulator.name} did not expose a live simulator screen. Switched to ${nextSimulator.name}.`, ); } - }, [failedStreamUDIDs, selectedSimulator, simulators, streamError]); + }, [ + failedStreamUDIDs, + fixedSimulatorUDID, + selectedSimulator, + simulators, + streamError, + ]); const shouldRenderChrome = selectedSimulator != null && shouldRenderNativeChrome(selectedSimulator); const viewportChromeProfile = shouldRenderChrome ? chromeProfile : null; @@ -499,10 +530,14 @@ export function AppShell() { }, [accessibilitySelectedId, selectedSimulator?.udid]); useEffect(() => { - if (selectedSimulator && selectedSimulator.udid !== selectedUDID) { + if ( + !fixedSimulatorUDID && + selectedSimulator && + selectedSimulator.udid !== selectedUDID + ) { setSelectedUDID(selectedSimulator.udid); } - }, [selectedSimulator, selectedUDID]); + }, [fixedSimulatorUDID, selectedSimulator, selectedUDID]); useEffect(() => { const nextViewportState = selectedSimulator @@ -805,7 +840,9 @@ export function AppShell() { }); const pairingRequired = - listError === AUTH_REQUIRED_MESSAGE && !accessTokenFromLocation(); + pairingEnabled && + listError === AUTH_REQUIRED_MESSAGE && + !accessTokenFromLocation(); const error = pairingRequired ? localError || streamError : localError || streamError || listError; @@ -1221,6 +1258,7 @@ export function AppShell() { error={error} filteredSimulators={filteredSimulators} hierarchyVisible={hierarchyVisible} + hideSimulatorSelection={hideSimulatorSelection} isLoading={isLoading} menuOpen={menuOpen} menuRef={menuRef} diff --git a/client/src/embedded.ts b/client/src/embedded.ts new file mode 100644 index 00000000..9d4b7bf2 --- /dev/null +++ b/client/src/embedded.ts @@ -0,0 +1,3 @@ +export { AppShell as SimDeckClient } from "./app/AppShell"; +export type { AppShellProps as SimDeckClientProps } from "./app/AppShell"; +export type { SimulatorMetadata } from "./api/types"; diff --git a/client/src/features/simulators/SimulatorMenu.tsx b/client/src/features/simulators/SimulatorMenu.tsx index d8b7ce22..90159689 100644 --- a/client/src/features/simulators/SimulatorMenu.tsx +++ b/client/src/features/simulators/SimulatorMenu.tsx @@ -6,6 +6,7 @@ import { SimulatorRow } from "./SimulatorRow"; interface SimulatorMenuProps { debugVisible: boolean; filteredSimulators: SimulatorMetadata[]; + hideSimulatorSelection?: boolean; isLoading: boolean; menuOpen: boolean; menuRef: RefObject; @@ -23,6 +24,7 @@ interface SimulatorMenuProps { export function SimulatorMenu({ debugVisible, filteredSimulators, + hideSimulatorSelection = false, isLoading, menuOpen, menuRef, @@ -53,29 +55,33 @@ export function SimulatorMenu({ className="menu-popover" onPointerDown={(event) => event.stopPropagation()} > - onChangeSearch(event.target.value)} - placeholder="Search simulators…" - value={search} - /> -
- {isLoading ?

Loading…

: null} - {!isLoading && filteredSimulators.length === 0 ? ( -

No matches

- ) : null} - {filteredSimulators.map((simulator) => ( - { - setSelectedUDID(simulator.udid); - onCloseMenu(); - }} - simulator={simulator} + {!hideSimulatorSelection ? ( + <> + onChangeSearch(event.target.value)} + placeholder="Search simulators..." + value={search} /> - ))} -
+
+ {isLoading ?

Loading...

: null} + {!isLoading && filteredSimulators.length === 0 ? ( +

No matches

+ ) : null} + {filteredSimulators.map((simulator) => ( + { + setSelectedUDID(simulator.udid); + onCloseMenu(); + }} + simulator={simulator} + /> + ))} +
+ + ) : null} {selectedSimulator ? ( <>
diff --git a/client/src/features/stream/streamWorkerClient.ts b/client/src/features/stream/streamWorkerClient.ts index 6cf49061..2b420df3 100644 --- a/client/src/features/stream/streamWorkerClient.ts +++ b/client/src/features/stream/streamWorkerClient.ts @@ -1,4 +1,5 @@ import { apiHeaders, fetchHealth } from "../../api/client"; +import { apiUrl } from "../../api/config"; import type { HealthResponse } from "../../api/types"; import { createEmptyStreamStats } from "./stats"; import type { @@ -138,7 +139,10 @@ class WebRtcStreamClient implements StreamClientBackend { (video as HTMLVideoElement & { latencyHint?: string }).latencyHint = "interactive"; video.srcObject = stream; - canvasElement.after(video); + canvasElement.parentElement?.insertBefore( + video, + canvasElement.nextSibling, + ); this.video = video; const startPlayback = () => { if (generation !== this.connectGeneration) { @@ -459,12 +463,15 @@ class WebRtcStreamClient implements StreamClientBackend { url: window.location.href, userAgent: window.navigator.userAgent, }; - void fetch(new URL("/api/client-stream-stats", window.location.href), { - body: JSON.stringify(payload), - cache: "no-store", - headers: apiHeaders(), - method: "POST", - }).catch(() => { + void fetch( + new URL(apiUrl("/api/client-stream-stats"), window.location.href), + { + body: JSON.stringify(payload), + cache: "no-store", + headers: apiHeaders(), + method: "POST", + }, + ).catch(() => { // Diagnostics only. }); } @@ -583,14 +590,17 @@ function postWebRtcOffer( udid: string, localDescription: RTCSessionDescription, ): Promise { - return fetch(`/api/simulators/${encodeURIComponent(udid)}/webrtc/offer`, { - body: JSON.stringify({ - sdp: localDescription.sdp, - type: localDescription.type, - }), - headers: apiHeaders(), - method: "POST", - }); + return fetch( + apiUrl(`/api/simulators/${encodeURIComponent(udid)}/webrtc/offer`), + { + body: JSON.stringify({ + sdp: localDescription.sdp, + type: localDescription.type, + }), + headers: apiHeaders(), + method: "POST", + }, + ); } function configureLowLatencyReceiver(receiver: RTCRtpReceiver) { diff --git a/client/src/features/stream/useLiveStream.ts b/client/src/features/stream/useLiveStream.ts index a07d923b..2ac94e45 100644 --- a/client/src/features/stream/useLiveStream.ts +++ b/client/src/features/stream/useLiveStream.ts @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { apiHeaders } from "../../api/client"; +import { apiUrl } from "../../api/config"; import type { SimulatorMetadata } from "../../api/types"; import type { Size } from "../viewport/types"; import { createEmptyStreamStats } from "./stats"; @@ -57,7 +58,10 @@ function createClientTelemetryId(): string { } function buildClientTelemetryUrl(): string { - return new URL("/api/client-stream-stats", window.location.href).toString(); + return new URL( + apiUrl("/api/client-stream-stats"), + window.location.href, + ).toString(); } export function useLiveStream({ diff --git a/client/src/features/toolbar/Toolbar.tsx b/client/src/features/toolbar/Toolbar.tsx index 1dadb43c..5929ffc9 100644 --- a/client/src/features/toolbar/Toolbar.tsx +++ b/client/src/features/toolbar/Toolbar.tsx @@ -8,6 +8,7 @@ interface ToolbarProps { error: string; filteredSimulators: SimulatorMetadata[]; hierarchyVisible: boolean; + hideSimulatorSelection?: boolean; isLoading: boolean; onBoot: () => void; onChangeSearch: (value: string) => void; @@ -40,6 +41,7 @@ export function Toolbar({ error, filteredSimulators, hierarchyVisible, + hideSimulatorSelection = false, isLoading, menuOpen, menuRef, @@ -98,6 +100,7 @@ export function Toolbar({