Merge pull request #134 from streamwall/unit-test-coverage-2

Makes control.js testable with various config. changes. Adds coverage for util.js, roles.js, starts on control.js. Updates the entrypoint
This commit is contained in:
Ben Menesini
2024-08-10 23:34:41 -07:00
committed by GitHub
13 changed files with 1583 additions and 777 deletions

1
__mocks__/fileMock.js Normal file
View File

@@ -0,0 +1 @@
module.exports = {};

View File

@@ -1,5 +1,7 @@
{
"presets": ["@babel/preset-env"],
"presets": [
["@babel/preset-env", { "targets": { "node": "current" } }]
],
"plugins": [
"@babel/plugin-proposal-optional-chaining",
"@babel/plugin-proposal-nullish-coalescing-operator",
@@ -10,6 +12,7 @@
"pragma": "h",
"pragmaFrag": "Fragment"
}
]
],
["@babel/plugin-proposal-decorators", { "legacy": false, "decoratorsBeforeExport": true }]
]
}

19
jest.config.js Normal file
View File

@@ -0,0 +1,19 @@
module.exports = {
verbose: true,
moduleFileExtensions: ['js', 'jsx', 'json', 'node'],
moduleNameMapper: {
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
'<rootDir>/__mocks__/fileMock.js',
'\\.(css|less)$': 'identity-obj-proxy',
"^preact(/(.*)|$)": "preact$1"
},
testEnvironment: 'node',
transform: {
'^.+\\.jsx?$': 'babel-jest',
},
testPathIgnorePatterns: ['/node_modules/'],
coveragePathIgnorePatterns: ['/node_modules/'],
collectCoverage: true,
coverageReporters: ['json', 'lcov', 'text', 'clover'],
testEnvironment: 'jsdom'
};

2140
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,8 +5,10 @@
"main": "src/index.js",
"scripts": {
"build": "webpack",
"prune": "rm -rf dist",
"start": "npm run build -- --stats=errors-only && electron dist",
"start-local": "npm run build -- --stats=errors-only && electron dist --control.address=http://localhost:4444 --control.username=streamwall --control.password=local-dev",
"start-dev": "npm run build -- --stats=verbose && electron dist --enable-logging --control.address=http://localhost:4444 --control.username=streamwall --control.password=local-dev",
"test-full": "jest",
"test": "jest --ci --reporters=default --reporters=jest-junit --testPathIgnorePatterns=src/node/server.test.js --coverage"
},
@@ -47,22 +49,27 @@
},
"devDependencies": {
"@babel/core": "^7.21.4",
"@babel/plugin-proposal-decorators": "^7.24.1",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.6",
"@babel/plugin-proposal-optional-chaining": "^7.21.0",
"@babel/plugin-transform-react-jsx": "^7.21.0",
"@babel/preset-env": "^7.21.4",
"@babel/preset-env": "^7.24.5",
"@svgr/webpack": "^7.0.0",
"babel-jest": "^29.5.0",
"babel-jest": "^29.7.0",
"babel-loader": "^9.1.2",
"babel-plugin-styled-components": "^2.1.1",
"bufferutil": "^4.0.8",
"copy-webpack-plugin": "^11.0.0",
"css-loader": "^6.7.3",
"file-loader": "^6.2.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^29.5.0",
"jest-environment-jsdom": "^29.7.0",
"jest-junit": "^16.0.0",
"prettier": "2.8.7",
"style-loader": "^3.3.2",
"supertest": "^6.3.3",
"utf-8-validate": "^5.0.10",
"webpack": "^5.79.0",
"webpack-cli": "^5.0.1"
},

View File

@@ -218,8 +218,10 @@ async function main(argv) {
callback(false)
})
console.debug('Loading persistence data...')
const persistData = await persistence.load()
console.debug('Creating StreamWindow...')
const idGen = new StreamIDGenerator()
const localStreamData = new LocalStreamData()
const overlayStreamData = new LocalStreamData()
@@ -235,6 +237,7 @@ async function main(argv) {
})
streamWindow.init()
console.debug('Creating Auth...')
const auth = new Auth({
adminUsername: argv.control.username,
adminPassword: argv.control.password,
@@ -246,6 +249,7 @@ async function main(argv) {
let twitchBot = null
let streamdelayClient = null
console.debug('Creating initial state...')
let clientState = new StateWrapper({
config: {
width: argv.window.width,
@@ -290,21 +294,29 @@ async function main(argv) {
})
const onMessage = async (msg, respond) => {
console.debug('Received message:', msg)
if (msg.type === 'set-listening-view') {
console.debug('Setting listening view:', msg.viewIdx)
streamWindow.setListeningView(msg.viewIdx)
} else if (msg.type === 'set-view-background-listening') {
console.debug('Setting view background listening:', msg.viewIdx, msg.listening)
streamWindow.setViewBackgroundListening(msg.viewIdx, msg.listening)
} else if (msg.type === 'set-view-blurred') {
console.debug('Setting view blurred:', msg.viewIdx, msg.blurred)
streamWindow.setViewBlurred(msg.viewIdx, msg.blurred)
} else if (msg.type === 'rotate-stream') {
console.debug('Rotating stream:', msg.url, msg.rotation)
overlayStreamData.update(msg.url, {
rotation: msg.rotation,
})
} else if (msg.type === 'update-custom-stream') {
console.debug('Updating custom stream:', msg.url)
localStreamData.update(msg.url, msg.data)
} else if (msg.type === 'delete-custom-stream') {
console.debug('Deleting custom stream:', msg.url)
localStreamData.delete(msg.url)
} else if (msg.type === 'reload-view') {
console.debug('Reloading view:', msg.viewIdx)
streamWindow.reloadView(msg.viewIdx)
} else if (msg.type === 'browse' || msg.type === 'dev-tools') {
if (
@@ -327,16 +339,26 @@ async function main(argv) {
})
}
if (msg.type === 'browse') {
ensureValidURL(msg.url)
browseWindow.loadURL(msg.url)
} else if (msg.type === 'dev-tools') {
console.debug('Attempting to browse URL:', msg.url)
try {
ensureValidURL(msg.url)
browseWindow.loadURL(msg.url)
} catch (error) {
console.error('Invalid URL:', msg.url)
console.error('Error:', error)
}
} else if (msg.type === 'dev-tools') {
console.debug('Opening DevTools for view:', msg.viewIdx)
streamWindow.openDevTools(msg.viewIdx, browseWindow.webContents)
}
} else if (msg.type === 'set-stream-censored' && streamdelayClient) {
console.debug('Setting stream censored:', msg.isCensored)
streamdelayClient.setCensored(msg.isCensored)
} else if (msg.type === 'set-stream-running' && streamdelayClient) {
console.debug('Setting stream running:', msg.isStreamRunning)
streamdelayClient.setStreamRunning(msg.isStreamRunning)
} else if (msg.type === 'create-invite') {
console.debug('Creating invite for role:', msg.role)
const { secret } = await auth.createToken({
kind: 'invite',
role: msg.role,
@@ -344,6 +366,7 @@ async function main(argv) {
})
respond({ name: msg.name, secret })
} else if (msg.type === 'delete-token') {
console.debug('Deleting token:', msg.tokenId)
auth.deleteToken(msg.tokenId)
}
}
@@ -357,6 +380,7 @@ async function main(argv) {
}
if (argv.control.address) {
console.debug('Initializing web server...')
const webDistPath = path.join(app.getAppPath(), 'web')
await initWebServer({
certDir: argv.cert.dir,
@@ -378,6 +402,7 @@ async function main(argv) {
}
if (argv.streamdelay.key) {
console.debug('Setting up Streamdelay client...')
streamdelayClient = new StreamdelayClient({
endpoint: argv.streamdelay.endpoint,
key: argv.streamdelay.key,
@@ -389,6 +414,7 @@ async function main(argv) {
}
if (argv.twitch.token) {
console.debug('Setting up Twitch bot...')
twitchBot = new TwitchBot(argv.twitch)
twitchBot.on('setListeningView', (idx) => {
streamWindow.setListeningView(idx)
@@ -410,34 +436,42 @@ async function main(argv) {
})
const dataSources = [
...argv.data['json-url'].map((url) =>
markDataSource(pollDataURL(url, argv.data.interval), 'json-url'),
),
...argv.data['toml-file'].map((path) =>
markDataSource(watchDataFile(path), 'toml-file'),
),
...argv.data['json-url'].map((url) => {
console.debug('Setting data source from json-url:', url)
return markDataSource(pollDataURL(url, argv.data.interval), 'json-url')
}),
...argv.data['toml-file'].map((path) => {
console.debug('Setting data source from toml-file:', path)
return markDataSource(watchDataFile(path), 'toml-file')
}),
markDataSource(localStreamData.gen(), 'custom'),
overlayStreamData.gen(),
]
for await (const rawStreams of combineDataSources(dataSources)) {
console.debug('Processing streams:', rawStreams)
const streams = idGen.process(rawStreams)
updateState({ streams })
}
}
function init() {
console.debug('Parsing command line arguments...')
const argv = parseArgs()
if (argv.help) {
return
}
console.debug('Initializing Sentry...')
if (argv.telemetry.sentry) {
Sentry.init({ dsn: SENTRY_DSN })
}
console.debug('Setting up Electron...')
app.commandLine.appendSwitch('high-dpi-support', 1)
app.commandLine.appendSwitch('force-device-scale-factor', 1)
console.debug('Enabling Electron sandbox...')
app.enableSandbox()
app
.whenReady()
@@ -449,5 +483,6 @@ function init() {
}
if (require.main === module) {
console.debug('Starting Streamwall...')
init()
}

View File

@@ -244,6 +244,7 @@ export default async function initWebServer({
onMessage,
stateDoc,
}) {
console.debug('Parsing URL:', baseURL)
let { protocol, hostname, port } = new URL(baseURL)
if (!port) {
port = protocol === 'https:' ? 443 : 80
@@ -252,6 +253,7 @@ export default async function initWebServer({
port = overridePort
}
console.debug('Initializing web server:', { hostname, port })
const { app } = initApp({
auth,
baseURL,

39
src/roles.test.js Normal file
View File

@@ -0,0 +1,39 @@
import { roleCan } from './roles.js';
describe('roleCan', () => {
it('should return true for admin role regardless of action', () => {
expect(roleCan('admin', 'any-action')).toBe(true);
});
it('should return true for operator role and valid action', () => {
expect(roleCan('operator', 'set-listening-view')).toBe(true);
});
it('should return false for operator role and invalid action', () => {
expect(roleCan('operator', 'invalid-action')).toBe(false);
});
it('should return false for operator role and un-granted action', () => {
expect(roleCan('operator', 'dev-tools')).toBe(false);
});
it('should return true for monitor role and valid action', () => {
expect(roleCan('monitor', 'set-view-blurred')).toBe(true);
});
it('should return false for monitor role and invalid action', () => {
expect(roleCan('monitor', 'invalid-action')).toBe(false);
});
it('should return false for monitor role and un-granted action', () => {
expect(roleCan('monitor', 'set-listening-view')).toBe(false);
});
it('should return false for invalid role regardless of action', () => {
expect(roleCan('invalid-role', 'any-action')).toBe(false);
});
it('should return false for invalid role and valid action', () => {
expect(roleCan('invalid-role', 'set-listening-view')).toBe(false);
});
});

17
src/util.test.js Normal file
View File

@@ -0,0 +1,17 @@
import { ensureValidURL } from './util'
describe('ensureValidURL', () => {
it('should not throw an error for valid http and https URLs', () => {
expect(() => ensureValidURL('http://example.com')).not.toThrow()
expect(() => ensureValidURL('https://example.com')).not.toThrow()
})
it('should throw an error for non-http and non-https URLs', () => {
expect(() => ensureValidURL('ftp://example.com')).toThrow()
expect(() => ensureValidURL('file://example.com')).toThrow()
})
it('should throw an error for invalid URLs', () => {
expect(() => ensureValidURL('invalid')).toThrow()
})
})

View File

@@ -1489,15 +1489,16 @@ const TIN = styled.div`
font-family: monospace;
`
function main() {
export function main() {
const script = document.getElementById('main-script')
const wsEndpoint = typeof script?.dataset?.wsEndpoint === 'string' ? script.dataset.wsEndpoint : 'defaultWsEndpoint';
const role = typeof script?.dataset?.role === 'string' ? script.dataset.role : 'defaultRole';
render(
<>
<GlobalStyle />
<App wsEndpoint={script.dataset.wsEndpoint} role={script.dataset.role} />
<App wsEndpoint={wsEndpoint} role={role} />
</>,
document.body,
)
}
main()

50
src/web/control.test.js Normal file
View File

@@ -0,0 +1,50 @@
import { filterStreams, useYDoc, useStreamwallConnection } from './control.js'
// import { renderHook, act } from '@testing-library/react-hooks'
describe("control test always passes", () => {
it("always passes", () => {
expect(true).toBe(true);
});
});
// describe('filterStreams', () => {
// it('should correctly filter live and other streams', () => {
// const streams = [
// { kind: 'video', status: 'Live' },
// { kind: 'audio', status: 'Offline' },
// { kind: 'video', status: 'Offline' },
// ]
// const [liveStreams, otherStreams] = filterStreams(streams)
// expect(liveStreams).toHaveLength(1)
// expect(otherStreams).toHaveLength(2)
// })
// })
// describe('useYDoc', () => {
// it('should initialize with an empty Y.Doc', () => {
// const { result } = renderHook(() => useYDoc(['test']))
// expect(result.current[0]).toEqual({})
// })
// it('should update docValue when doc is updated', () => {
// const { result } = renderHook(() => useYDoc(['test']))
// act(() => {
// result.current[1].getMap('test').set('key', 'value')
// })
// expect(result.current[0]).toEqual({ test: { key: 'value' } })
// })
// })
// describe('useStreamwallConnection', () => {
// it('should initialize with default values', () => {
// const { result } = renderHook(() => useStreamwallConnection('ws://localhost:8080'))
// expect(result.current.isConnected).toBe(false)
// expect(result.current.config).toEqual({})
// expect(result.current.streams).toEqual([])
// expect(result.current.customStreams).toEqual([])
// expect(result.current.views).toEqual([])
// expect(result.current.stateIdxMap).toEqual(new Map())
// expect(result.current.delayState).toBeUndefined()
// expect(result.current.authState).toBeUndefined()
// })
// })

3
src/web/entrypoint.js Normal file
View File

@@ -0,0 +1,3 @@
import { main } from './control.js';
main();

View File

@@ -108,7 +108,7 @@ const webConfig = {
devtool: 'cheap-source-map',
target: 'web',
entry: {
control: './src/web/control.js',
control: './src/web/entrypoint.js',
},
output: {
path: path.resolve(__dirname, 'dist/web'),
@@ -118,6 +118,13 @@ const webConfig = {
patterns: [{ from: 'src/web/*.ejs', to: '[name].ejs' }],
}),
],
stats: {
colors: true,
modules: true,
reasons: true,
errorDetails: true,
warnings: true,
}
}
module.exports = [nodeConfig, browserConfig, webConfig]