This commit is contained in:
2025-08-18 21:51:28 -04:00
parent 1eb0637d38
commit f3ecb8c35b
19 changed files with 179 additions and 646 deletions

10
eslint.config.js Normal file
View File

@@ -0,0 +1,10 @@
import js from '@eslint/js';
import globals from 'globals';
import tseslint from 'typescript-eslint';
import {defineConfig} from 'eslint/config';
export default defineConfig([
{files: ['**/*.{js,mjs,cjs,ts}'], plugins: {js}, extends: ['js/recommended']},
{files: ['**/*.{js,mjs,cjs,ts}'], languageOptions: {globals: globals.node}},
tseslint.configs.recommended
]);

View File

@@ -3,16 +3,21 @@
"version": "2025.0.0",
"type": "module",
"private": true,
"scripts": {
"lint": "eslint .",
"lint:fix": "eslint . --fix"
},
"devDependencies": {
"@types/bun": "latest"
"@eslint/js": "9.33.0",
"@types/bun": "latest",
"globals": "16.3.0",
"typescript-eslint": "8.40.0"
},
"peerDependencies": {
"typescript": "5.9.8"
},
"dependencies": {
"@techniker-me/pcast-api": "2025.0.2",
"@techniker-me/rtmp-push": "2025.0.2",
"commander": "14.0.0",
"eslint": "8"
"eslint": "9.33.0"
}
}

View File

@@ -1,4 +1,4 @@
import SupportedBrowser from "./SupportedBrowser";
import SupportedBrowser from './SupportedBrowser';
// Source: https://github.com/browserstack/api
@@ -13,7 +13,7 @@ export class BrowserstackApi {
public async getSupportedBrowsers(): Promise<SupportedBrowser[]> {
const endpoint = `${this._baseUrl}/browsers?flat=true`;
const headers = {
'Authorization': this._authorizationHeader
Authorization: this._authorizationHeader
};
const response = await fetch(endpoint, {headers});

View File

@@ -5,6 +5,6 @@ type SupportedBrowser = {
device: string;
browser_version: string | null;
real_mobile: boolean;
}
};
export default SupportedBrowser;

View File

@@ -32,7 +32,15 @@ export default class CommandLine {
public static parse(args: string[]): CommandLineOptions {
CommandLine._program.parse(args);
return CommandLine._program.opts<CommandLineOptions>();
const rawOptions = CommandLine._program.opts();
// Convert the string log level back to LoggingLevel enum
const logLevel = rawOptions.logLevel ? LoggingLevelMapping.convertLoggingLevelTypeToLoggingLevel(rawOptions.logLevel) : defaultLogLevel;
return {
...rawOptions,
logLevel
} as CommandLineOptions;
}
static {
@@ -55,15 +63,25 @@ export default class CommandLine {
CommandLine._program.option('--ingest-uri <ingestUri>', 'The ingest URI to use');
CommandLine._program.option('--publisher-uri <publisherUri>', 'The publisher URI to use');
CommandLine._program.option('--viewer <browser@version:OS@OSVersion...>', 'The browser and OS for simulating a viewer to use', handleArrayOption, defaultViewers);
CommandLine._program.option('--publisher <browser@version:OS@OSVersion...>', 'The browser and OS for simulating a publisher to use', handleArrayOption, defaultPublishers);
CommandLine._program.option(
'--viewer <browser@version:OS@OSVersion...>',
'The browser and OS for simulating a viewer to use',
handleArrayOption,
defaultViewers
);
CommandLine._program.option(
'--publisher <browser@version:OS@OSVersion...>',
'The browser and OS for simulating a publisher to use',
handleArrayOption,
defaultPublishers
);
CommandLine._program.option('-t, --test <test...>', 'The test to run', handleArrayOption, defaultTests);
CommandLine._program.option('--use-browserstack', 'Run tests using BrowserStack', defaultUseBrowserstack);
CommandLine._program.option('--use-browserstack-local', 'Run tests using BrowserStack Local', defaultUseBrowserstackLocal);
CommandLine._program.option('--browserstack-user <username>', 'The BrowserStack username to use', process.env.BROWSERSTACK_USER || '');
CommandLine._program.option('--browserstack-key <key>', 'The BrowserStack key to use', process.env.BROWSERSTACK_KEY || '');
CommandLine._program.option('--log-level <logLevel>', 'The log level to use', LoggingLevelMapping.convertLoggingLevelToLoggingLevelType(defaultLogLevel));
}
};
setupProgramOptions();
}

View File

@@ -1,24 +1,23 @@
import CommandLine, { CommandLineOptions } from './CommandLine';
import type {CommandLineOptions} from './CommandLine';
type ApplicationCredentials = {
applicationId: string;
secret: string;
}
};
type Uri = {
pcast: string;
ingest: string | undefined;
channel: string | undefined;
publisher: string | undefined;
}
};
type BrowserstackConfiguration = {
enabled: boolean;
local: boolean;
user: string;
key: string;
}
};
export default class TestConfiguration {
private readonly _applicationCredentials: ApplicationCredentials;
@@ -31,14 +30,14 @@ export default class TestConfiguration {
constructor(commandLineOptions: CommandLineOptions) {
this._applicationCredentials = {
applicationId: commandLineOptions.applicationId,
secret: commandLineOptions.secret,
secret: commandLineOptions.secret
};
this._uri = {
pcast: commandLineOptions.pcastUri,
ingest: commandLineOptions.ingestUri,
channel: commandLineOptions.channelUri,
publisher: commandLineOptions.publisherUri,
publisher: commandLineOptions.publisherUri
};
this._viewers = commandLineOptions.viewers;
@@ -48,7 +47,7 @@ export default class TestConfiguration {
enabled: commandLineOptions.useBrowserstack,
local: commandLineOptions.useBrowserstackLocal,
user: commandLineOptions.browserstackUser,
key: commandLineOptions.browserstackKey,
key: commandLineOptions.browserstackKey
};
}

View File

@@ -96,7 +96,8 @@ export class Logger {
// Append remaining parameters if any
if (paramIndex < optionalParameters.length) {
const remainingParams = optionalParameters.slice(paramIndex)
const remainingParams = optionalParameters
.slice(paramIndex)
.map(param => this.parameterToString(param))
.join(' ');
result += ` ${remainingParams}`;

View File

@@ -1,7 +1,7 @@
import {ConsoleAppender} from './appenders/ConsoleAppender';
import IAppender from './appenders/IAppender';
import {Logger} from './Logger';
import {LoggingLevel, LoggingLevelMapping, LoggingLevelType} from './LoggingLevel';
import {LoggingLevel, LoggingLevelMapping} from './LoggingLevel';
import {Threshold} from './Threshold';
export default class LoggerFactory {

View File

@@ -1,10 +1,8 @@
import {browser} from '@wdio/globals';
export type PageOptions = {
browser?: typeof browser; // MultiRemote usecase
}
};
export type PageOpenOptions = {
queryParameters?: Record<string, string | number>;
@@ -22,7 +20,9 @@
public async open(options: PageOpenOptions = {}): Promise<void> {
const {queryParameters, isNewTabRequest, endpoint, requestPath} = options;
const pageUrl = `${this._baseUrl}/${endpoint}${requestPath}?${Object.entries(queryParameters ?? {}).map(([queryParameterName, queryParamterValue]) => (`${queryParameterName}=${queryParamterValue}&`)).join('')}`;
const pageUrl = `${this._baseUrl}/${endpoint}${requestPath}?${Object.entries(queryParameters ?? {})
.map(([queryParameterName, queryParamterValue]) => `${queryParameterName}=${queryParamterValue}&`)
.join('')}`;
if (isNewTabRequest) {
await browser.newWindow(pageUrl);

View File

@@ -14,5 +14,4 @@ export class SubscribingPage extends Page {
public async open(options?: PageOpenOptions): Promise<void> {
await super.open(options);
}
}

View File

@@ -1,203 +0,0 @@
import {before, describe, it, after} from 'mocha';
import {expect, use} from 'chai';
import ChaiAsPromised from 'chai-as-promised';
import {SubscribingPage} from '../pages';
use(ChaiAsPromised);
// Helper function to add delays and make tests more observable
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
// Simple, working test implementation
describe('Subscribe Workflow Tests', () => {
let subscribingPage: SubscribingPage;
before(async () => {
console.log('\n🚀 Starting Subscribe Workflow Tests...');
console.log('⏱️ Tests will run with delays for real-time observation');
await delay(1000);
// Initialize the page object with a real URL
const testUrl = 'http://dl.phenixrts.com/JsSDK/2025.2.latest/examples/channel-viewer-plain.html';
subscribingPage = new SubscribingPage(testUrl, {});
console.log(`✅ SubscribingPage initialized with URL: ${testUrl}`);
await delay(500);
});
after(async () => {
console.log('\n🧹 Test cleanup completed');
});
describe('RealTime Stream Subscription', () => {
it('should initialize page object correctly', async () => {
console.log('🔍 Testing page initialization...');
await delay(300);
expect(subscribingPage).to.exist;
console.log('✅ Page object exists');
await delay(200);
expect(subscribingPage).to.be.instanceOf(SubscribingPage);
console.log('✅ Page object is correct instance');
await delay(200);
});
it('should open the streaming page successfully', async () => {
console.log('🌐 Testing page navigation...');
await delay(500);
try {
// Open the page with test parameters
await subscribingPage.open({
queryParameters: {
channelId: 'test-channel-realtime',
token: 'test-token-realtime',
streamType: 'realtime'
}
});
console.log('✅ Page opened successfully');
await delay(1000);
// Wait for page to load
console.log('⏳ Waiting for page to load...');
await delay(2000);
} catch (error) {
console.warn('⚠️ Page navigation failed:', error);
throw error; // Fail the test if navigation fails
}
});
it('should find video element on the page', async () => {
console.log('🔍 Looking for video element...');
await delay(500);
try {
const videoElement = await subscribingPage.videoElement;
console.log('📺 Video element found:', videoElement ? 'Yes' : 'No');
if (videoElement) {
expect(videoElement).to.exist;
console.log('✅ Video element exists and is accessible');
} else {
console.log('⚠️ No video element found - this might indicate a page loading issue');
// Don't fail the test, but log the issue
}
} catch (error) {
console.warn('⚠️ Video element test failed:', error);
// Don't fail the test for video element issues
}
await delay(500);
});
});
describe('HLS Stream Subscription', () => {
it('should handle HLS stream configuration', async () => {
console.log('🔍 Testing HLS stream configuration...');
await delay(300);
const streamConfig = {
type: 'hls',
url: 'https://example.com/stream.m3u8',
quality: 'high'
};
expect(streamConfig.type).to.equal('hls');
console.log('✅ HLS stream type configured');
await delay(200);
expect(streamConfig.url).to.include('.m3u8');
console.log('✅ HLS playlist URL format correct');
await delay(200);
});
});
describe('DASH Stream Subscription', () => {
it('should handle DASH stream configuration', async () => {
console.log('🔍 Testing DASH stream configuration...');
await delay(300);
const streamConfig = {
type: 'dash',
url: 'https://example.com/stream.mpd',
quality: 'adaptive'
};
expect(streamConfig.type).to.equal('dash');
console.log('✅ DASH stream type configured');
await delay(200);
expect(streamConfig.url).to.include('.mpd');
console.log('✅ DASH manifest URL format correct');
await delay(200);
});
});
describe('WebRTC Stream Subscription', () => {
it('should handle WebRTC stream configuration', async () => {
console.log('🔍 Testing WebRTC stream configuration...');
await delay(300);
const streamConfig = {
type: 'webrtc',
url: 'wss://example.com/webrtc',
latency: 'low'
};
expect(streamConfig.type).to.equal('webrtc');
console.log('✅ WebRTC stream type configured');
await delay(200);
expect(streamConfig.url).to.include('wss://');
console.log('✅ WebRTC WebSocket URL format correct');
await delay(200);
});
});
describe('Stream Subscription Workflow', () => {
it('should demonstrate complete subscription workflow', async () => {
console.log('🔍 Testing complete subscription workflow...');
await delay(500);
// Step 1: Channel setup
const channel = {
id: 'workflow-test-channel',
name: 'Workflow Test Channel',
status: 'active'
};
expect(channel.status).to.equal('active');
console.log('✅ Channel setup completed');
await delay(300);
// Step 2: Authentication
const auth = {
token: 'workflow-test-token',
expires: Date.now() + 3600000, // 1 hour from now
permissions: ['view', 'subscribe']
};
expect(auth.permissions).to.include('subscribe');
console.log('✅ Authentication configured');
await delay(300);
// Step 3: Stream connection
const connection = {
status: 'connecting',
streamType: 'realtime',
quality: 'high'
};
expect(connection.status).to.equal('connecting');
console.log('✅ Stream connection initiated');
await delay(300);
// Step 4: Subscription active
connection.status = 'active';
expect(connection.status).to.equal('active');
console.log('✅ Subscription active');
await delay(300);
console.log('🎯 Complete workflow test passed');
});
});
});

View File

@@ -1,98 +0,0 @@
import { ITestContext, IChannelTestContext, IPublisherTestContext, ISubscriberTestContext } from '../interfaces/ITestContext';
export interface TestContextConfig {
applicationCredentials: {
id: string;
secret: string;
};
uri: {
pcast: string;
ingest: string;
channel: string;
};
streamKind: string;
}
export class TestContextFactory {
private static createBaseContext(config: TestContextConfig): ITestContext {
return {
applicationCredentials: config.applicationCredentials,
uri: config.uri,
streamKind: config.streamKind,
cleanup: [],
addCleanupTask: function(task: () => Promise<void>): void {
this.cleanup.push(task);
},
executeCleanup: async function(): Promise<void> {
for (const task of this.cleanup) {
try {
await task();
} catch (error) {
console.error('Cleanup task failed:', error);
}
}
}
};
}
static createChannelContext(config: TestContextConfig): IChannelTestContext {
const baseContext = this.createBaseContext(config);
try {
// Try to import the real PCastApi if available
const PCastApi = require('@techniker-me/pcast-api');
const pcastApi = new PCastApi(config.uri.pcast, config.applicationCredentials);
return {
...baseContext,
pcastApi,
channel: null as any, // Will be set later
};
} catch (error) {
console.warn('Real PCastApi not available, using mock API:', error);
// Return mock context for testing
return {
...baseContext,
pcastApi: {
createChannel: async () => ({ channelId: 'mock-channel' }),
deleteChannel: async () => ({ status: 'ok' })
} as any,
channel: null as any,
};
}
}
static createPublisherContext(config: TestContextConfig): IPublisherTestContext {
const channelContext = this.createChannelContext(config);
try {
// Try to import the real RtmpPush if available
const RtmpPush = require('@techniker-me/rtmp-push');
return {
...channelContext,
publishSource: new RtmpPush(),
};
} catch (error) {
console.warn('Real RtmpPush not available, using mock:', error);
return {
...channelContext,
publishSource: {} as any,
};
}
}
static createSubscriberContext(config: TestContextConfig): ISubscriberTestContext {
const channelContext = this.createChannelContext(config);
return {
...channelContext,
publishDestination: {
token: '',
capabilities: [],
},
};
}
}

View File

@@ -1,32 +0,0 @@
export interface ITestContext {
readonly applicationCredentials: {
readonly id: string;
readonly secret: string;
};
readonly uri: {
readonly pcast: string;
readonly ingest: string;
readonly channel: string;
};
readonly streamKind: string;
readonly cleanup: (() => Promise<void>)[];
addCleanupTask(task: () => Promise<void>): void;
executeCleanup(): Promise<void>;
}
export interface IChannelTestContext extends ITestContext {
channel: any; // Mutable for test setup
readonly pcastApi: any; // Replace 'any' with proper PCastApi type
}
export interface IPublisherTestContext extends IChannelTestContext {
readonly publishSource: any; // Replace 'any' with proper RtmpPush type
}
export interface ISubscriberTestContext extends IChannelTestContext {
publishDestination: { // Mutable for test setup
token: string;
readonly capabilities: string[];
};
}

View File

@@ -1,53 +0,0 @@
import { IChannelTestContext } from '../interfaces/ITestContext';
// Mock Channel type for now to avoid import issues
interface MockChannel {
channelId: string;
status?: string;
}
export class ChannelService {
static async createTestChannel(context: IChannelTestContext): Promise<MockChannel> {
try {
// Try to import the real API if available
const { Channel } = await import('@techniker-me/pcast-api');
const channelAlias = `UAT#${new Date().toISOString()}#${context.streamKind}`;
const channelDescription = `UAT#SubscribeWorkflow#${context.streamKind}`;
const channelOptions: string[] = [];
const channel = await context.pcastApi.createChannel(channelAlias, channelDescription, channelOptions);
// Add cleanup task
context.addCleanupTask(async () => {
console.log(`${new Date().toISOString()} [SubscribeWorkflow] [cleanup] deleting channel [${JSON.stringify(channel, null, 2)}]`);
try {
const deleteResponse = await context.pcastApi.deleteChannel(channel.channelId);
if (deleteResponse.status !== 'ok') {
throw new Error(`[SubscribeWorkflow] [cleanup] Error: Unable to delete test channel due to [${JSON.stringify(deleteResponse, null, 2)}]`);
}
} catch (error) {
console.error('Failed to delete channel during cleanup:', error);
}
});
return channel;
} catch (error) {
console.warn('Real PCast API not available, using mock channel:', error);
// Return mock channel for testing
const mockChannel: MockChannel = {
channelId: `mock-${Date.now()}-${context.streamKind}`
};
// Add mock cleanup task
context.addCleanupTask(async () => {
console.log(`${new Date().toISOString()} [SubscribeWorkflow] [cleanup] cleaning up mock channel [${mockChannel.channelId}]`);
});
return mockChannel;
}
}
}

View File

@@ -1,34 +0,0 @@
import { ISubscriberTestContext } from '../interfaces/ITestContext';
export class TokenService {
static generateSubscriberToken(context: ISubscriberTestContext): string {
try {
// Try to import the real TokenBuilder if available
const TokenBuilder = require('phenix-edge-auth');
const { browser } = require('@wdio/globals');
const { capabilities: { browserName, browserVersion, platformName } } = browser;
const subscriberTag = `UAT#${new Date().toISOString()}#SubscribeWorkflow#${context.streamKind}#${browserName}@${browserVersion}:${platformName}`;
const tokenBuilder = new TokenBuilder()
.withApplicationId(context.applicationCredentials.id)
.withSecret(context.applicationCredentials.secret)
.expiresInSeconds(3600)
.forChannel(context.channel.channelId)
.applyTags(subscriberTag);
// Add capabilities if they exist
context.publishDestination.capabilities?.forEach(subscriberCapability =>
tokenBuilder.withCapability(subscriberCapability)
);
return tokenBuilder.build();
} catch (error) {
console.warn('Real TokenBuilder not available, using mock token:', error);
// Return mock token for testing
return `mock-token-${Date.now()}-${context.streamKind}`;
}
}
}

View File

@@ -1,79 +0,0 @@
export interface IStreamKindStrategy {
getStreamKind(): string;
getChannelOptions(): string[];
getTestDescription(): string;
}
export class RealTimeStreamStrategy implements IStreamKindStrategy {
getStreamKind(): string {
return 'RealTime';
}
getChannelOptions(): string[] {
return [];
}
getTestDescription(): string {
return 'RealTime stream subscription test';
}
}
export class ChunkedStreamHLSStrategy implements IStreamKindStrategy {
getStreamKind(): string {
return 'ChunkedStream HLS';
}
getChannelOptions(): string[] {
return ['hls'];
}
getTestDescription(): string {
return 'ChunkedStream HLS subscription test';
}
}
export class ChunkedStreamDASHStrategy implements IStreamKindStrategy {
getStreamKind(): string {
return 'ChunkedStream DASH';
}
getChannelOptions(): string[] {
return ['dash'];
}
getTestDescription(): string {
return 'ChunkedStream DASH subscription test';
}
}
// Example of how easy it is to add new stream types
export class WebRTCStreamStrategy implements IStreamKindStrategy {
getStreamKind(): string {
return 'WebRTC';
}
getChannelOptions(): string[] {
return ['webrtc', 'low-latency'];
}
getTestDescription(): string {
return 'WebRTC stream subscription test';
}
}
export class StreamKindStrategyFactory {
static createStrategy(streamType: string): IStreamKindStrategy {
switch (streamType.toLowerCase()) {
case 'realtime':
return new RealTimeStreamStrategy();
case 'hls':
return new ChunkedStreamHLSStrategy();
case 'dash':
return new ChunkedStreamDASHStrategy();
case 'webrtc':
return new WebRTCStreamStrategy();
default:
throw new Error(`Unknown stream type: ${streamType}`);
}
}
}