Skip to content

Commit e290898

Browse files
committed
test: add webrtc stream benchmark
1 parent 8b52248 commit e290898

7 files changed

Lines changed: 433 additions & 15 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,42 @@ jobs:
242242
- name: JS API simulator integration tests
243243
run: npm run test:integration:js-api
244244

245+
integration-webrtc:
246+
name: WebRTC stream benchmark
247+
runs-on: macos-15
248+
timeout-minutes: 20
249+
env:
250+
DEVELOPER_DIR: /Applications/Xcode_16.4.app/Contents/Developer
251+
needs:
252+
- rust
253+
- client
254+
- packages
255+
- build-artifacts
256+
257+
steps:
258+
- uses: actions/checkout@v4
259+
260+
- uses: actions/setup-node@v4
261+
with:
262+
node-version: 20
263+
264+
- name: Download integration artifacts
265+
uses: actions/download-artifact@v4
266+
with:
267+
name: simdeck-integration-artifacts
268+
path: .
269+
270+
- name: Make CLI executable
271+
run: chmod +x build/simdeck build/simdeck-bin
272+
273+
- name: WebRTC software stream benchmark
274+
run: npm run test:integration:webrtc
275+
env:
276+
SIMDECK_E2E_MIN_DECODED_FPS: "55"
277+
SIMDECK_E2E_MIN_PRESENTED_FPS: "55"
278+
SIMDECK_E2E_MIN_RECEIVED_FPS: "55"
279+
SIMDECK_E2E_WEBRTC_MS: "20000"
280+
245281
integration-android:
246282
name: Android emulator integration
247283
runs-on: ubuntu-latest

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
"test:integration:cli:verbose": "SIMDECK_INTEGRATION_VERBOSE=1 SIMDECK_INTEGRATION_SHOW_SIMULATOR=1 node scripts/integration/cli.mjs",
7070
"test:integration:fixture": "node scripts/integration/prebuild-fixture.mjs",
7171
"test:integration:js-api": "node scripts/integration/js-api.mjs",
72+
"test:integration:webrtc": "node scripts/integration/webrtc.mjs",
7273
"test:integration:js-api:verbose": "SIMDECK_INTEGRATION_SHOW_SIMULATOR=1 node scripts/integration/js-api.mjs",
7374
"test:integration:android": "node scripts/integration/android.mjs",
7475
"test:integration:android:verbose": "SIMDECK_INTEGRATION_VERBOSE=1 node scripts/integration/android.mjs",

‎packages/simdeck-test/dist/index.js‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -384,23 +384,24 @@ async function startIsolatedDaemon(cliPath, options) {
384384
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "simdeck-test-project-"));
385385
const metadataPath = path.join(os.tmpdir(), `simdeck-test-${process.pid}-${Date.now()}-${crypto.randomUUID()}.json`);
386386
const accessToken = crypto.randomBytes(32).toString("hex");
387+
const packageRoot = options.projectRoot ?? process.cwd();
387388
const child = spawn(cliPath, [
388-
"daemon",
389+
"service",
389390
"run",
390-
"--project-root",
391-
projectRoot,
392391
"--metadata-path",
393392
metadataPath,
394393
"--port",
395394
String(port),
396395
"--bind",
397396
"127.0.0.1",
397+
"--client-root",
398+
path.join(packageRoot, "packages", "client", "dist"),
398399
"--access-token",
399400
accessToken,
400401
"--video-codec",
401402
options.videoCodec ?? "software",
402403
], {
403-
cwd: options.projectRoot,
404+
cwd: projectRoot,
404405
stdio: ["ignore", "pipe", "pipe"],
405406
});
406407
const output = captureChildOutput(child);

‎packages/simdeck-test/src/index.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -726,26 +726,27 @@ async function startIsolatedDaemon(
726726
`simdeck-test-${process.pid}-${Date.now()}-${crypto.randomUUID()}.json`,
727727
);
728728
const accessToken = crypto.randomBytes(32).toString("hex");
729+
const packageRoot = options.projectRoot ?? process.cwd();
729730
const child = spawn(
730731
cliPath,
731732
[
732-
"daemon",
733+
"service",
733734
"run",
734-
"--project-root",
735-
projectRoot,
736735
"--metadata-path",
737736
metadataPath,
738737
"--port",
739738
String(port),
740739
"--bind",
741740
"127.0.0.1",
741+
"--client-root",
742+
path.join(packageRoot, "packages", "client", "dist"),
742743
"--access-token",
743744
accessToken,
744745
"--video-codec",
745746
options.videoCodec ?? "software",
746747
],
747748
{
748-
cwd: options.projectRoot,
749+
cwd: projectRoot,
749750
stdio: ["ignore", "pipe", "pipe"],
750751
},
751752
);

‎scripts/e2e-webrtc-reliability.mjs‎

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ const maxPeerDisconnectedMs = Number(
2727
process.env.SIMDECK_E2E_MAX_PEER_DISCONNECTED_MS ?? 1000,
2828
);
2929
const maxDecoderDrops = Number(process.env.SIMDECK_E2E_MAX_DECODER_DROPS ?? 0);
30+
const minVideoWidth = Number(process.env.SIMDECK_E2E_MIN_VIDEO_WIDTH ?? 0);
31+
const minVideoHeight = Number(process.env.SIMDECK_E2E_MIN_VIDEO_HEIGHT ?? 0);
32+
const minDecodedFps = Number(process.env.SIMDECK_E2E_MIN_DECODED_FPS ?? 0);
33+
const minPresentedFps = Number(process.env.SIMDECK_E2E_MIN_PRESENTED_FPS ?? 0);
34+
const minReceivedFps = Number(process.env.SIMDECK_E2E_MIN_RECEIVED_FPS ?? 0);
3035
const visualSampleIntervalMs = Number(
3136
process.env.SIMDECK_E2E_VISUAL_SAMPLE_INTERVAL_MS ?? 5000,
3237
);
@@ -406,6 +411,13 @@ try {
406411
directStatsEnd.packetsReceived - directStatsStart.packetsReceived;
407412
const directPresentedDelta =
408413
directStatsEnd.totalVideoFrames - directStatsStart.totalVideoFrames;
414+
const observedDurationSeconds = Math.max(
415+
0.001,
416+
(directStatsEnd.timestampMs - directStatsStart.timestampMs) / 1000,
417+
);
418+
const decodedFps = directDecodedDelta / observedDurationSeconds;
419+
const presentedFps = directPresentedDelta / observedDurationSeconds;
420+
const receivedFps = directPacketsDelta / observedDurationSeconds;
409421

410422
if (decodedDelta <= 0 || receivedDelta <= 0) {
411423
failures.push(
@@ -429,6 +441,41 @@ try {
429441
if (reconnectDelta > 0) {
430442
failures.push(`peer reconnected ${reconnectDelta} times`);
431443
}
444+
const finalVideoWidth = Math.max(
445+
numeric(finalPage.width),
446+
numeric(finalWebRtc.width),
447+
numeric(directStatsEnd.videoWidth),
448+
);
449+
const finalVideoHeight = Math.max(
450+
numeric(finalPage.height),
451+
numeric(finalWebRtc.height),
452+
numeric(directStatsEnd.videoHeight),
453+
);
454+
if (minVideoWidth > 0 && finalVideoWidth < minVideoWidth) {
455+
failures.push(
456+
`video width ${finalVideoWidth} did not meet minimum ${minVideoWidth}`,
457+
);
458+
}
459+
if (minVideoHeight > 0 && finalVideoHeight < minVideoHeight) {
460+
failures.push(
461+
`video height ${finalVideoHeight} did not meet minimum ${minVideoHeight}`,
462+
);
463+
}
464+
if (minDecodedFps > 0 && decodedFps < minDecodedFps) {
465+
failures.push(
466+
`decoded fps ${decodedFps.toFixed(2)} did not meet minimum ${minDecodedFps}`,
467+
);
468+
}
469+
if (minPresentedFps > 0 && presentedFps < minPresentedFps) {
470+
failures.push(
471+
`presented fps ${presentedFps.toFixed(2)} did not meet minimum ${minPresentedFps}`,
472+
);
473+
}
474+
if (minReceivedFps > 0 && receivedFps < minReceivedFps) {
475+
failures.push(
476+
`received packet fps ${receivedFps.toFixed(2)} did not meet minimum ${minReceivedFps}`,
477+
);
478+
}
432479
if (maxPeerDisconnectedObservedMs > maxPeerDisconnectedMs) {
433480
failures.push(
434481
`peer disconnected for ${maxPeerDisconnectedObservedMs}ms, exceeded ${maxPeerDisconnectedMs}ms`,
@@ -480,8 +527,14 @@ try {
480527
durationMs,
481528
finalPage,
482529
finalWebRtc,
530+
finalVideoHeight,
531+
finalVideoWidth,
483532
initialPage,
484533
initialWebRtc,
534+
observedDurationSeconds,
535+
decodedFps,
536+
presentedFps,
537+
receivedFps,
485538
maxObservedDecodeQueue,
486539
maxObservedFrameGapMs,
487540
maxPeerDisconnectedMs,
@@ -601,12 +654,15 @@ async function collectDirectWebRtcStats(cdp) {
601654
(async () => {
602655
const totals = {
603656
framesDecoded: 0,
604-
framesDropped: 0,
605-
jitter: 0,
606-
packetsLost: 0,
607-
packetsReceived: 0,
608-
totalVideoFrames: 0,
609-
};
657+
framesDropped: 0,
658+
jitter: 0,
659+
packetsLost: 0,
660+
packetsReceived: 0,
661+
timestampMs: Date.now(),
662+
totalVideoFrames: 0,
663+
videoHeight: 0,
664+
videoWidth: 0,
665+
};
610666
for (const pc of window.__simdeckPeerConnections || []) {
611667
const reports = await pc.getStats();
612668
for (const report of reports.values()) {
@@ -622,6 +678,8 @@ async function collectDirectWebRtcStats(cdp) {
622678
for (const video of document.querySelectorAll("video.stream-video")) {
623679
const quality = video.getVideoPlaybackQuality?.();
624680
totals.totalVideoFrames += quality?.totalVideoFrames || 0;
681+
totals.videoWidth = Math.max(totals.videoWidth, video.videoWidth || 0);
682+
totals.videoHeight = Math.max(totals.videoHeight, video.videoHeight || 0);
625683
}
626684
return totals;
627685
})()

‎scripts/integration/fixture.mjs‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,10 @@ function fixtureSource() {
140140
@interface FixtureViewController : UIViewController <UITextFieldDelegate>
141141
@property (nonatomic, strong) UILabel *statusLabel;
142142
@property (nonatomic, strong) UITextField *messageField;
143+
@property (nonatomic, strong) UIView *animationBar;
144+
@property (nonatomic, strong) CADisplayLink *displayLink;
143145
@property (nonatomic) NSInteger tapCount;
146+
@property (nonatomic) CFTimeInterval animationStartedAt;
144147
- (void)openFixtureURL:(NSURL *)url;
145148
@end
146149
@@ -178,11 +181,18 @@ function fixtureSource() {
178181
[self.messageField addTarget:self action:@selector(messageChanged:) forControlEvents:UIControlEventEditingChanged];
179182
[self.messageField.widthAnchor constraintEqualToConstant:240.0].active = YES;
180183
184+
self.animationBar = [[UIView alloc] initWithFrame:CGRectZero];
185+
self.animationBar.backgroundColor = UIColor.systemBlueColor;
186+
self.animationBar.layer.cornerRadius = 6.0;
187+
self.animationBar.accessibilityIdentifier = @"fixture.animation";
188+
self.animationBar.translatesAutoresizingMaskIntoConstraints = NO;
189+
181190
UIStackView *stack = [[UIStackView alloc] initWithArrangedSubviews:@[
182191
titleLabel,
183192
self.statusLabel,
184193
continueButton,
185194
self.messageField,
195+
self.animationBar,
186196
]];
187197
stack.axis = UILayoutConstraintAxisVertical;
188198
stack.alignment = UIStackViewAlignmentCenter;
@@ -195,6 +205,8 @@ function fixtureSource() {
195205
[stack.centerYAnchor constraintEqualToAnchor:self.view.centerYAnchor],
196206
[stack.leadingAnchor constraintGreaterThanOrEqualToAnchor:self.view.safeAreaLayoutGuide.leadingAnchor constant:24.0],
197207
[stack.trailingAnchor constraintLessThanOrEqualToAnchor:self.view.safeAreaLayoutGuide.trailingAnchor constant:-24.0],
208+
[self.animationBar.widthAnchor constraintEqualToConstant:220.0],
209+
[self.animationBar.heightAnchor constraintEqualToConstant:12.0],
198210
]];
199211
}
200212
@@ -207,8 +219,31 @@ function fixtureSource() {
207219
self.statusLabel.text = sender.text ?: @"";
208220
}
209221
222+
- (void)startAnimation {
223+
if (self.displayLink) {
224+
return;
225+
}
226+
self.animationStartedAt = CACurrentMediaTime();
227+
self.statusLabel.text = @"Animating";
228+
self.displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(animationTick:)];
229+
[self.displayLink addToRunLoop:NSRunLoop.mainRunLoop forMode:NSRunLoopCommonModes];
230+
}
231+
232+
- (void)animationTick:(CADisplayLink *)displayLink {
233+
CFTimeInterval elapsed = CACurrentMediaTime() - self.animationStartedAt;
234+
CGFloat phase = (sin(elapsed * 4.0) + 1.0) * 0.5;
235+
CGFloat x = -80.0 + phase * 160.0;
236+
self.animationBar.transform = CGAffineTransformMakeTranslation(x, 0.0);
237+
self.animationBar.backgroundColor = [UIColor colorWithHue:fmod(elapsed * 0.18, 1.0)
238+
saturation:0.85
239+
brightness:0.92
240+
alpha:1.0];
241+
}
242+
210243
- (void)openFixtureURL:(NSURL *)url {
211-
if ([url.host isEqualToString:@"focus-message"]) {
244+
if ([url.host isEqualToString:@"animate"]) {
245+
[self startAnimation];
246+
} else if ([url.host isEqualToString:@"focus-message"]) {
212247
dispatch_async(dispatch_get_main_queue(), ^{
213248
[self.messageField becomeFirstResponder];
214249
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(500 * NSEC_PER_MSEC)), dispatch_get_main_queue(), ^{

0 commit comments

Comments
 (0)