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", "version": "2025.0.0",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": {
"lint": "eslint .",
"lint:fix": "eslint . --fix"
},
"devDependencies": { "devDependencies": {
"@types/bun": "latest" "@eslint/js": "9.33.0",
"@types/bun": "latest",
"globals": "16.3.0",
"typescript-eslint": "8.40.0"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "5.9.8" "typescript": "5.9.8"
}, },
"dependencies": { "dependencies": {
"@techniker-me/pcast-api": "2025.0.2",
"@techniker-me/rtmp-push": "2025.0.2",
"commander": "14.0.0", "commander": "14.0.0",
"eslint": "8" "eslint": "9.33.0"
} }
} }

View File

@@ -1,4 +1,4 @@
import { browser } from '@wdio/globals'; import {browser} from '@wdio/globals';
export enum DocumentReadyState { export enum DocumentReadyState {
Loading = 'Loading', Loading = 'Loading',
@@ -7,9 +7,9 @@ export enum DocumentReadyState {
} }
export async function waitUntilDocumentReadyState(waitForReadyState: DocumentReadyState): Promise<void> { export async function waitUntilDocumentReadyState(waitForReadyState: DocumentReadyState): Promise<void> {
await browser.waitUntil(() => browser.execute(`document.readyState === "${DocumentReadyState[waitForReadyState]}"`) as Promise<boolean>, { await browser.waitUntil(() => browser.execute(`document.readyState === "${DocumentReadyState[waitForReadyState]}"`) as Promise<boolean>, {
timeout: 10000, timeout: 10000,
timeoutMsg: `Document did not have a readyState of [${waitForReadyState}] after [10] seconds`, timeoutMsg: `Document did not have a readyState of [${waitForReadyState}] after [10] seconds`,
interval: 1000 interval: 1000
}); });
} }

View File

@@ -1,27 +1,27 @@
import SupportedBrowser from "./SupportedBrowser"; import SupportedBrowser from './SupportedBrowser';
// Source: https://github.com/browserstack/api // Source: https://github.com/browserstack/api
export class BrowserstackApi { export class BrowserstackApi {
private readonly _baseUrl: string = 'https://api.browserstack.com/5'; private readonly _baseUrl: string = 'https://api.browserstack.com/5';
private readonly _authorizationHeader: string; private readonly _authorizationHeader: string;
constructor(username: string, accessKey: string) { constructor(username: string, accessKey: string) {
this._authorizationHeader = `Basic ${Buffer.from(`${username}:${accessKey}`).toString('base64')}`; this._authorizationHeader = `Basic ${Buffer.from(`${username}:${accessKey}`).toString('base64')}`;
}
public async getSupportedBrowsers(): Promise<SupportedBrowser[]> {
const endpoint = `${this._baseUrl}/browsers?flat=true`;
const headers = {
Authorization: this._authorizationHeader
};
const response = await fetch(endpoint, {headers});
if (!response.ok) {
throw new Error(`Failed to fetch BrowserStack supported browsers due to [ ${response.statusText}]`);
} }
public async getSupportedBrowsers(): Promise<SupportedBrowser[]> { return response.json() as Promise<SupportedBrowser[]>;
const endpoint = `${this._baseUrl}/browsers?flat=true`; }
const headers = { }
'Authorization': this._authorizationHeader
};
const response = await fetch(endpoint, { headers });
if (!response.ok) {
throw new Error(`Failed to fetch BrowserStack supported browsers due to [ ${response.statusText}]`);
}
return response.json() as Promise<SupportedBrowser[]>;
}
}

View File

@@ -1,10 +1,10 @@
type SupportedBrowser = { type SupportedBrowser = {
os: string; os: string;
os_version: string; os_version: string;
browser: string; browser: string;
device: string; device: string;
browser_version: string | null; browser_version: string | null;
real_mobile: boolean; real_mobile: boolean;
} };
export default SupportedBrowser; export default SupportedBrowser;

View File

@@ -27,43 +27,61 @@ const defaultUseBrowserstack = false;
const defaultUseBrowserstackLocal = false; const defaultUseBrowserstackLocal = false;
export default class CommandLine { export default class CommandLine {
private static readonly _program: Command = new Command(); private static readonly _program: Command = new Command();
public static parse(args: string[]): CommandLineOptions { public static parse(args: string[]): CommandLineOptions {
CommandLine._program.parse(args); 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 { static {
CommandLine._program.version(PackageJson.version); CommandLine._program.version(PackageJson.version);
const setupProgramOptions = (): void => { const setupProgramOptions = (): void => {
const handleArrayOption = (value: string, previousValue: string[]) => { const handleArrayOption = (value: string, previousValue: string[]) => {
if (previousValue) { if (previousValue) {
return previousValue.concat(value); return previousValue.concat(value);
} }
return [value]; return [value];
}; };
// Required options // Required options
CommandLine._program.requiredOption('--application-id <applicationId>', 'The application ID to use'); CommandLine._program.requiredOption('--application-id <applicationId>', 'The application ID to use');
CommandLine._program.requiredOption('--secret <secret>', 'The secret to use'); CommandLine._program.requiredOption('--secret <secret>', 'The secret to use');
CommandLine._program.requiredOption('--pcast-uri <pcastUri>', 'The pcast URI to use'); CommandLine._program.requiredOption('--pcast-uri <pcastUri>', 'The pcast URI to use');
CommandLine._program.requiredOption('--channel-uri <channelUri>', 'The channel URI to use'); CommandLine._program.requiredOption('--channel-uri <channelUri>', 'The channel URI to use');
CommandLine._program.option('--ingest-uri <ingestUri>', 'The ingest URI to use'); 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('--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(
CommandLine._program.option('--publisher <browser@version:OS@OSVersion...>', 'The browser and OS for simulating a publisher to use', handleArrayOption, defaultPublishers); '--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('-t, --test <test...>', 'The test to run', handleArrayOption, defaultTests);
CommandLine._program.option('--use-browserstack', 'Run tests using BrowserStack', defaultUseBrowserstack); 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('--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-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('--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)); CommandLine._program.option('--log-level <logLevel>', 'The log level to use', LoggingLevelMapping.convertLoggingLevelToLoggingLevelType(defaultLogLevel));
} };
setupProgramOptions(); setupProgramOptions();
} }

View File

@@ -1,78 +1,77 @@
import CommandLine, { CommandLineOptions } from './CommandLine'; import type {CommandLineOptions} from './CommandLine';
type ApplicationCredentials = { type ApplicationCredentials = {
applicationId: string; applicationId: string;
secret: string; secret: string;
} };
type Uri = { type Uri = {
pcast: string; pcast: string;
ingest: string | undefined; ingest: string | undefined;
channel: string | undefined; channel: string | undefined;
publisher: string | undefined; publisher: string | undefined;
} };
type BrowserstackConfiguration = { type BrowserstackConfiguration = {
enabled: boolean; enabled: boolean;
local: boolean; local: boolean;
user: string; user: string;
key: string; key: string;
} };
export default class TestConfiguration { export default class TestConfiguration {
private readonly _applicationCredentials: ApplicationCredentials; private readonly _applicationCredentials: ApplicationCredentials;
private readonly _uri: Uri; private readonly _uri: Uri;
private readonly _viewers: string[]; private readonly _viewers: string[];
private readonly _publishers: string[]; private readonly _publishers: string[];
private readonly _tests: string[]; private readonly _tests: string[];
private readonly _browserstack: BrowserstackConfiguration; private readonly _browserstack: BrowserstackConfiguration;
constructor(commandLineOptions: CommandLineOptions) { constructor(commandLineOptions: CommandLineOptions) {
this._applicationCredentials = { this._applicationCredentials = {
applicationId: commandLineOptions.applicationId, applicationId: commandLineOptions.applicationId,
secret: commandLineOptions.secret, secret: commandLineOptions.secret
}; };
this._uri = { this._uri = {
pcast: commandLineOptions.pcastUri, pcast: commandLineOptions.pcastUri,
ingest: commandLineOptions.ingestUri, ingest: commandLineOptions.ingestUri,
channel: commandLineOptions.channelUri, channel: commandLineOptions.channelUri,
publisher: commandLineOptions.publisherUri, publisher: commandLineOptions.publisherUri
}; };
this._viewers = commandLineOptions.viewers; this._viewers = commandLineOptions.viewers;
this._publishers = commandLineOptions.publishers; this._publishers = commandLineOptions.publishers;
this._tests = commandLineOptions.tests; this._tests = commandLineOptions.tests;
this._browserstack = { this._browserstack = {
enabled: commandLineOptions.useBrowserstack, enabled: commandLineOptions.useBrowserstack,
local: commandLineOptions.useBrowserstackLocal, local: commandLineOptions.useBrowserstackLocal,
user: commandLineOptions.browserstackUser, user: commandLineOptions.browserstackUser,
key: commandLineOptions.browserstackKey, key: commandLineOptions.browserstackKey
}; };
} }
get applicationCredentials(): ApplicationCredentials { get applicationCredentials(): ApplicationCredentials {
return this._applicationCredentials; return this._applicationCredentials;
} }
get uri(): Uri { get uri(): Uri {
return this._uri; return this._uri;
} }
get viewers(): string[] { get viewers(): string[] {
return this._viewers; return this._viewers;
} }
get publishers(): string[] { get publishers(): string[] {
return this._publishers; return this._publishers;
} }
get tests(): string[] { get tests(): string[] {
return this._tests; return this._tests;
} }
get browserstack(): BrowserstackConfiguration { get browserstack(): BrowserstackConfiguration {
return this._browserstack; return this._browserstack;
} }
} }

View File

@@ -65,7 +65,7 @@ export class Logger {
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
const formattedMessage = this.formatMessage(message, ...optionalParameters); const formattedMessage = this.formatMessage(message, ...optionalParameters);
const levelString = LoggingLevelMapping.convertLoggingLevelToLoggingLevelType(level); const levelString = LoggingLevelMapping.convertLoggingLevelToLoggingLevelType(level);
// Safely call appenders with error handling // Safely call appenders with error handling
this._appenders.forEach(appender => { this._appenders.forEach(appender => {
try { try {
@@ -86,22 +86,23 @@ export class Logger {
// More efficient parameter substitution // More efficient parameter substitution
let result = message; let result = message;
let paramIndex = 0; let paramIndex = 0;
// Replace all {} placeholders with parameters // Replace all {} placeholders with parameters
while (result.includes('{}') && paramIndex < optionalParameters.length) { while (result.includes('{}') && paramIndex < optionalParameters.length) {
const paramString = this.parameterToString(optionalParameters[paramIndex]); const paramString = this.parameterToString(optionalParameters[paramIndex]);
result = result.replace('{}', paramString); result = result.replace('{}', paramString);
paramIndex++; paramIndex++;
} }
// Append remaining parameters if any // Append remaining parameters if any
if (paramIndex < optionalParameters.length) { if (paramIndex < optionalParameters.length) {
const remainingParams = optionalParameters.slice(paramIndex) const remainingParams = optionalParameters
.slice(paramIndex)
.map(param => this.parameterToString(param)) .map(param => this.parameterToString(param))
.join(' '); .join(' ');
result += ` ${remainingParams}`; result += ` ${remainingParams}`;
} }
return result; return result;
} }

View File

@@ -1,20 +1,20 @@
import { ConsoleAppender } from './appenders/ConsoleAppender'; import {ConsoleAppender} from './appenders/ConsoleAppender';
import IAppender from './appenders/IAppender'; import IAppender from './appenders/IAppender';
import {Logger} from './Logger'; import {Logger} from './Logger';
import {LoggingLevel, LoggingLevelMapping, LoggingLevelType} from './LoggingLevel'; import {LoggingLevel, LoggingLevelMapping} from './LoggingLevel';
import {Threshold} from './Threshold'; import {Threshold} from './Threshold';
export default class LoggerFactory { export default class LoggerFactory {
private static readonly _loggerForCategory: Map<string, Logger> = new Map(); private static readonly _loggerForCategory: Map<string, Logger> = new Map();
private static readonly _threshold: {level: LoggingLevel} = {level: LoggingLevel.Info}; private static readonly _threshold: {level: LoggingLevel} = {level: LoggingLevel.Info};
private static readonly _appenders: IAppender[] = [new ConsoleAppender()]; private static readonly _appenders: IAppender[] = [new ConsoleAppender()];
public static getLogger(category: string): Logger { public static getLogger(category: string): Logger {
let logger = LoggerFactory._loggerForCategory.get(category); let logger = LoggerFactory._loggerForCategory.get(category);
if (logger === undefined) { if (logger === undefined) {
logger = new Logger({category, threshold: new Threshold(LoggerFactory._threshold), appenders: LoggerFactory._appenders}); logger = new Logger({category, threshold: new Threshold(LoggerFactory._threshold), appenders: LoggerFactory._appenders});
LoggerFactory._loggerForCategory.set(category, logger); LoggerFactory._loggerForCategory.set(category, logger);
} }
@@ -29,4 +29,4 @@ export default class LoggerFactory {
private constructor() { private constructor() {
throw new Error('[LoggerFactory] is a static class that may not be instantiated'); throw new Error('[LoggerFactory] is a static class that may not be instantiated');
} }
} }

View File

@@ -12,19 +12,19 @@ export class ConsoleAppender implements IAppender {
console.info(`${timestamp} [${category}] ${message}`); console.info(`${timestamp} [${category}] ${message}`);
break; break;
case LoggingLevel.Error: case LoggingLevel.Error:
console.error(`${timestamp} [${category}] ${message}`); console.error(`${timestamp} [${category}] ${message}`);
break; break;
case LoggingLevel.Fatal: case LoggingLevel.Fatal:
console.error(`${timestamp} [${category}] ${message}`); console.error(`${timestamp} [${category}] ${message}`);
break; break;
case LoggingLevel.Off: case LoggingLevel.Off:
case LoggingLevel.All: case LoggingLevel.All:
break; break;
default: default:
assertUnreachable(level); assertUnreachable(level);
} }
} }

View File

@@ -1,33 +1,33 @@
import {browser} from '@wdio/globals'; import {browser} from '@wdio/globals';
export type PageOptions = {
browser?: typeof browser; // MultiRemote usecase
};
export type PageOptions = { export type PageOpenOptions = {
browser?: typeof browser; // MultiRemote usecase queryParameters?: Record<string, string | number>;
isNewTabRequest?: boolean;
endpoint?: string;
requestPath?: string;
};
} export default class Page {
private readonly _baseUrl: string;
export type PageOpenOptions = { constructor(baseUrl: string) {
queryParameters?: Record<string, string | number>;
isNewTabRequest?: boolean;
endpoint?: string;
requestPath?: string;
};
export default class Page {
private readonly _baseUrl: string;
constructor(baseUrl: string) {
this._baseUrl = baseUrl; this._baseUrl = baseUrl;
} }
public async open(options: PageOpenOptions = {}): Promise<void> { public async open(options: PageOpenOptions = {}): Promise<void> {
const {queryParameters, isNewTabRequest, endpoint, requestPath} = options; 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) { if (isNewTabRequest) {
await browser.newWindow(pageUrl); await browser.newWindow(pageUrl);
} else { } else {
await browser.url(pageUrl); await browser.url(pageUrl);
}
} }
} }
}

View File

@@ -1,6 +1,6 @@
import Page, { PageOpenOptions } from './Page.ts'; import Page, {PageOpenOptions} from './Page.ts';
export type SubscribingPageOptions = { }; export type SubscribingPageOptions = {};
export class SubscribingPage extends Page { export class SubscribingPage extends Page {
constructor(baseUri: string, options: SubscribingPageOptions) { constructor(baseUri: string, options: SubscribingPageOptions) {
@@ -12,7 +12,6 @@ export class SubscribingPage extends Page {
} }
public async open(options?: PageOpenOptions): Promise<void> { public async open(options?: PageOpenOptions): Promise<void> {
await super.open(options); await super.open(options);
} }
} }

View File

@@ -1,6 +1,6 @@
import LoggerFactory from '../logger/LoggerFactory'; import LoggerFactory from '../logger/LoggerFactory';
import CommandLine from '../config/CommandLine'; import CommandLine from '../config/CommandLine';
import { CommandLineOptions } from '../config/CommandLine'; import {CommandLineOptions} from '../config/CommandLine';
export default class TestRunner { export default class TestRunner {
private static readonly _logger = LoggerFactory.getLogger('TestRunner'); private static readonly _logger = LoggerFactory.getLogger('TestRunner');
@@ -16,4 +16,4 @@ export default class TestRunner {
TestRunner._logger.info('TestRunner started'); TestRunner._logger.info('TestRunner started');
TestRunner._logger.info(JSON.stringify(TestRunner._commandLineOptions, null, 2)); TestRunner._logger.info(JSON.stringify(TestRunner._commandLineOptions, null, 2));
} }
} }

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}`);
}
}
}