Skip to content

Commit 4bd0d2f

Browse files
test(e2e): add Convert and Stream view end-to-end tests (#55)
* test(e2e): add Convert and Stream view end-to-end tests Add Playwright e2e tests covering: - Convert view with Audio Mixing pipeline (API + UI tests) - Stream view with MoQ session lifecycle and connection tests Infrastructure changes: - Add data-testid to ConvertView and StreamView for test selectors - Configure fake audio device and microphone permissions in Playwright - Pass MoQ gateway URL to e2e server harness Signed-off-by: StreamKit Devin <devin@streamkit.dev> Co-Authored-By: Claudio Costa <devin@streamkit.dev> * fix(e2e): resolve test failures in convert and stream specs - API test: use browser-side fetch with early abort to avoid timeout from streaming response body (mixing pipeline streams in real-time) - Session lifecycle test: filter QUIC/TLS/cert errors from auto-connect - MoQ connection test: handle auto-connect behavior, gracefully skip when WebTransport connection cannot be established - Remove unused 'request' import from convert.spec.ts Signed-off-by: StreamKit Devin <devin@streamkit.dev> Co-Authored-By: Claudio Costa <devin@streamkit.dev> * fix(e2e): use .first() for Disconnect button to avoid strict mode violation When MoQ is connected, two Disconnect buttons are rendered (one in SessionPanel, one in Connection & Controls). Use .first() to resolve the strict mode ambiguity. Signed-off-by: StreamKit Devin <devin@streamkit.dev> Co-Authored-By: Claudio Costa <devin@streamkit.dev> * refactor(e2e): shared console error helper, audio playback verification, robust MoQ error filtering - Extract console error collection into shared test-helpers.ts with createConsoleErrorCollector() and configurable benign pattern filtering - Replace broad BENIGN_PATTERNS with specific MOQ_BENIGN_PATTERNS (QUIC_TLS_CERTIFICATE_UNKNOWN instead of ERR_QUIC_PROTOCOL_ERROR) - Add verifyAudioPlayback() helper to check audio element has loaded and started playback in convert tests - Add installAudioContextTracker() / verifyAudioContextActive() to verify Hang is receiving/decoding/playing audio via Web Audio API - Fix sessionId extraction from UI so afterEach cleanup actually works - Clear sessionId after successful session destruction Signed-off-by: StreamKit Devin <devin@streamkit.dev> Co-Authored-By: Claudio Costa <devin@streamkit.dev> * style(e2e): fix prettier formatting to match CI config Signed-off-by: StreamKit Devin <devin@streamkit.dev> Co-Authored-By: Claudio Costa <devin@streamkit.dev> * fix(e2e): stop console error collection before disconnect/destroy phase WebTransport teardown emits benign 'The session is closed' errors during disconnect. Assert and stop the collector before the disconnect/destroy phase so shutdown noise does not cause false failures. Signed-off-by: StreamKit Devin <devin@streamkit.dev> Co-Authored-By: Claudio Costa <devin@streamkit.dev> * docs(e2e): add documentation comments to helpers and tricky logic Signed-off-by: StreamKit Devin <devin@streamkit.dev> Co-Authored-By: Claudio Costa <devin@streamkit.dev> --------- Signed-off-by: StreamKit Devin <devin@streamkit.dev> Co-authored-by: StreamKit Devin <devin@streamkit.dev>
1 parent f4c5fc4 commit 4bd0d2f

File tree

7 files changed

+564
-3
lines changed

7 files changed

+564
-3
lines changed

e2e/playwright.config.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,16 @@
22
//
33
// SPDX-License-Identifier: MPL-2.0
44

5+
import * as path from 'path';
6+
57
import { defineConfig, devices } from '@playwright/test';
68

79
// E2E_BASE_URL is set by the harness runner (run.ts) or passed externally
810
const baseURL = process.env.E2E_BASE_URL;
911

12+
// Path to a WAV file used by Chromium as a fake microphone source
13+
const fakeAudioPath = path.resolve(import.meta.dirname, '../samples/audio/system/speech_10m.wav');
14+
1015
if (!baseURL) {
1116
throw new Error(
1217
'E2E_BASE_URL environment variable is required. ' +
@@ -37,7 +42,16 @@ export default defineConfig({
3742
projects: [
3843
{
3944
name: 'chromium',
40-
use: { ...devices['Desktop Chrome'] },
45+
use: {
46+
...devices['Desktop Chrome'],
47+
launchOptions: {
48+
args: [
49+
'--use-fake-device-for-media-stream',
50+
`--use-file-for-fake-audio-capture=${fakeAudioPath}`,
51+
],
52+
},
53+
permissions: ['microphone'],
54+
},
4155
},
4256
],
4357
});

e2e/src/harness/run.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ async function startServer(): Promise<ServerInfo> {
115115
env: {
116116
...process.env,
117117
SK_SERVER__ADDRESS: `127.0.0.1:${port}`,
118+
SK_SERVER__MOQ_GATEWAY_URL: `https://127.0.0.1:${port}/moq`,
118119
SK_LOG__FILE_ENABLE: 'false', // Avoid writing skit.log
119120
...(enableAuth
120121
? {

e2e/tests/convert.spec.ts

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
// SPDX-FileCopyrightText: © 2025 StreamKit Contributors
2+
//
3+
// SPDX-License-Identifier: MPL-2.0
4+
5+
import * as fs from 'fs';
6+
import * as path from 'path';
7+
8+
import { test, expect } from '@playwright/test';
9+
10+
import { ensureLoggedIn, getAuthHeaders } from './auth-helpers';
11+
import {
12+
type ConsoleErrorCollector,
13+
createConsoleErrorCollector,
14+
verifyAudioPlayback,
15+
} from './test-helpers';
16+
17+
const repoRoot = path.resolve(import.meta.dirname, '..', '..');
18+
const sampleOggPath = path.join(repoRoot, 'samples', 'audio', 'system', 'sample.ogg');
19+
const mixingYaml = fs.readFileSync(
20+
path.join(repoRoot, 'samples', 'pipelines', 'oneshot', 'mixing.yml'),
21+
'utf8'
22+
);
23+
24+
test.describe('Convert View - Audio Mixing Pipeline', () => {
25+
let collector: ConsoleErrorCollector;
26+
27+
test.beforeEach(async ({ page }) => {
28+
collector = createConsoleErrorCollector(page);
29+
await page.goto('/convert');
30+
await ensureLoggedIn(page);
31+
if (!page.url().includes('/convert')) {
32+
await page.goto('/convert');
33+
}
34+
await expect(page.getByTestId('convert-view')).toBeVisible();
35+
});
36+
37+
// This test runs the mixing pipeline at the API level to verify the server
38+
// can process audio. We use page.evaluate() to issue the request from the
39+
// browser context so it shares the same origin/cookies, and we read only the
40+
// *first chunk* of the streamed response before cancelling. This avoids a
41+
// timeout: the mixing pipeline streams output in real-time, so waiting for
42+
// the full body would take as long as the audio duration.
43+
test('API: POST /api/v1/process with mixing pipeline returns audio', async ({
44+
page,
45+
baseURL,
46+
}) => {
47+
const audioBase64 = fs.readFileSync(sampleOggPath).toString('base64');
48+
const authHeaders = getAuthHeaders();
49+
50+
const result = await page.evaluate(
51+
async ({ url, yaml, audio, headers }) => {
52+
const formData = new FormData();
53+
formData.append('config', yaml);
54+
const bytes = Uint8Array.from(atob(audio), (c) => c.charCodeAt(0));
55+
formData.append('media', new Blob([bytes], { type: 'audio/ogg' }), 'sample.ogg');
56+
57+
const controller = new AbortController();
58+
const timeoutId = setTimeout(() => controller.abort(), 30_000);
59+
60+
try {
61+
const response = await fetch(`${url}/api/v1/process`, {
62+
method: 'POST',
63+
body: formData,
64+
headers,
65+
signal: controller.signal,
66+
});
67+
68+
const contentType = response.headers.get('content-type') ?? '';
69+
// Read only the first chunk to confirm audio is being produced,
70+
// then cancel the stream to avoid waiting for real-time playback.
71+
const reader = response.body!.getReader();
72+
const { value } = await reader.read();
73+
reader.cancel();
74+
75+
return {
76+
status: response.status,
77+
contentType,
78+
firstChunkSize: value?.length ?? 0,
79+
};
80+
} finally {
81+
clearTimeout(timeoutId);
82+
}
83+
},
84+
{
85+
url: baseURL,
86+
yaml: mixingYaml,
87+
audio: audioBase64,
88+
headers: authHeaders,
89+
}
90+
);
91+
92+
expect(result.status, `Process request failed: ${result.status}`).toBe(200);
93+
expect(
94+
result.contentType.includes('audio/') ||
95+
result.contentType.includes('video/webm') ||
96+
result.contentType.includes('application/octet'),
97+
`Unexpected Content-Type: ${result.contentType}`
98+
).toBeTruthy();
99+
expect(result.firstChunkSize).toBeGreaterThan(0);
100+
});
101+
102+
test('UI: select mixing template, upload file, convert, verify audio player', async ({
103+
page,
104+
}) => {
105+
await expect(page.getByText('1. Select Pipeline Template')).toBeVisible();
106+
107+
const templateCard = page.getByText('Audio Mixing (Upload + Music Track)', {
108+
exact: true,
109+
});
110+
await expect(templateCard).toBeVisible({ timeout: 10_000 });
111+
await templateCard.click();
112+
113+
await expect(page.locator('input[type="file"]').first()).toBeAttached();
114+
await page.locator('input[type="file"]').first().setInputFiles(sampleOggPath);
115+
116+
await expect(page.getByText('sample.ogg')).toBeVisible();
117+
118+
const convertButton = page.getByRole('button', { name: /Convert File/i });
119+
await expect(convertButton).toBeEnabled();
120+
await convertButton.click();
121+
122+
await expect(page.getByText('Converted Audio')).toBeVisible({
123+
timeout: 60_000,
124+
});
125+
126+
const playback = await verifyAudioPlayback(page);
127+
expect(playback.found, 'Audio element not found on page').toBe(true);
128+
expect(playback.duration, 'Audio has no duration').toBeGreaterThan(0);
129+
130+
const unexpected = collector.getUnexpected();
131+
expect(unexpected, `Unexpected console errors: ${unexpected.join('; ')}`).toHaveLength(0);
132+
});
133+
134+
test('UI: select mixing template, use existing asset, convert, verify audio player', async ({
135+
page,
136+
}) => {
137+
await expect(page.getByText('1. Select Pipeline Template')).toBeVisible();
138+
139+
const templateCard = page.getByText('Audio Mixing (Upload + Music Track)', {
140+
exact: true,
141+
});
142+
await expect(templateCard).toBeVisible({ timeout: 10_000 });
143+
await templateCard.click();
144+
145+
const assetModeButton = page.getByRole('button', {
146+
name: /Select Existing Asset/i,
147+
});
148+
await expect(assetModeButton).toBeVisible();
149+
await assetModeButton.click();
150+
151+
const assetRadioGroup = page.locator('[aria-label="Audio asset selection"]');
152+
await expect(assetRadioGroup).toBeVisible({ timeout: 10_000 });
153+
154+
const firstAsset = assetRadioGroup.locator('label').first();
155+
await expect(firstAsset).toBeVisible();
156+
await firstAsset.click();
157+
158+
const convertButton = page.getByRole('button', { name: /Convert File/i });
159+
await expect(convertButton).toBeEnabled();
160+
await convertButton.click();
161+
162+
await expect(page.getByText('Converted Audio')).toBeVisible({
163+
timeout: 60_000,
164+
});
165+
166+
const playback = await verifyAudioPlayback(page);
167+
expect(playback.found, 'Audio element not found on page').toBe(true);
168+
expect(playback.duration, 'Audio has no duration').toBeGreaterThan(0);
169+
170+
const unexpected = collector.getUnexpected();
171+
expect(unexpected, `Unexpected console errors: ${unexpected.join('; ')}`).toHaveLength(0);
172+
});
173+
});

0 commit comments

Comments
 (0)