Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ jobs:
needs: build-macos
runs-on: ${{ matrix.image }}
strategy:
# TEMP: browser-source probe branch (the unbundled control step fails by design)
fail-fast: false
matrix:
BuildReleases: [Release-x86_64, Release-arm64]
include:
Expand Down Expand Up @@ -144,6 +146,7 @@ jobs:
# https://docs.github.com/en/actions/reference/workflows-and-actions/contexts#steps-context
id: run_tests
timeout-minutes: 30
# TEMP: browser-source probe branch (test:ci runs only test_browser_source_load)
run: 'yarn run test:ci'
env:
SLOBS_BE_STREAMKEY: ${{secrets.testsStreamKey}}
Expand All @@ -152,6 +155,77 @@ jobs:
OSN_SECRET_ACCESS_KEY: ${{secrets.AWS_RELEASE_SECRET_ACCESS_KEY}}
RELEASE_NAME: ${{matrix.ReleaseName}}
CI: true
# TEMP: browser-source probe branch
- name: 'Run tests (unbundled layout)'
if: ${{ always() }}
timeout-minutes: 30
shell: bash
run: |
set -euo pipefail
UNBUNDLED_ROOT="$RUNNER_TEMP/osn-unbundled"
rm -rf "$UNBUNDLED_ROOT"
mkdir -p "$UNBUNDLED_ROOT"
# ditto preserves symlinks and permissions; the destination has no .app component.
ditto "${{env.SLFullDistributePath}}" "$UNBUNDLED_ROOT/distribute"
OSN_DIR="$UNBUNDLED_ROOT/distribute/${{env.InstallPath}}"
# Point the tests at the copy instead of the install prefix baked in by CMake.
printf "export * from '%s/index';\n\nconst wd: string = \"%s\";\nexport {wd};\n" "$OSN_DIR" "$OSN_DIR" > tests/osn-tests/osn/index.ts
cat tests/osn-tests/osn/index.ts
yarn run test:ci
env:
SLOBS_BE_STREAMKEY: ${{secrets.testsStreamKey}}
SLOBS_TEST_USER_POOL_TOKEN: ${{secrets.testsUserPoolToken}}
OSN_ACCESS_KEY_ID: ${{secrets.AWS_RELEASE_ACCESS_KEY_ID}}
OSN_SECRET_ACCESS_KEY: ${{secrets.AWS_RELEASE_SECRET_ACCESS_KEY}}
RELEASE_NAME: ${{matrix.ReleaseName}}
CI: true
OSN_PROBE_DISABLE_MAIN_BUNDLE_FIX: '1'
# TEMP: browser-source probe branch
- name: 'Run tests (unbundled + fix)'
if: ${{ always() }}
timeout-minutes: 30
shell: bash
run: |
set -euo pipefail
UNBUNDLED_ROOT="$RUNNER_TEMP/osn-unbundled-fix"
rm -rf "$UNBUNDLED_ROOT"
mkdir -p "$UNBUNDLED_ROOT"
# ditto preserves symlinks and permissions; the destination has no .app component.
ditto "${{env.SLFullDistributePath}}" "$UNBUNDLED_ROOT/distribute"
OSN_DIR="$UNBUNDLED_ROOT/distribute/${{env.InstallPath}}"
# Point the tests at the copy instead of the install prefix baked in by CMake.
printf "export * from '%s/index';\n\nconst wd: string = \"%s\";\nexport {wd};\n" "$OSN_DIR" "$OSN_DIR" > tests/osn-tests/osn/index.ts
cat tests/osn-tests/osn/index.ts
yarn run test:ci
env:
SLOBS_BE_STREAMKEY: ${{secrets.testsStreamKey}}
SLOBS_TEST_USER_POOL_TOKEN: ${{secrets.testsUserPoolToken}}
OSN_ACCESS_KEY_ID: ${{secrets.AWS_RELEASE_ACCESS_KEY_ID}}
OSN_SECRET_ACCESS_KEY: ${{secrets.AWS_RELEASE_SECRET_ACCESS_KEY}}
RELEASE_NAME: ${{matrix.ReleaseName}}
CI: true
# TEMP: browser-source probe branch
- name: 'Run tests (shim.app, fix disabled)'
if: ${{ always() }}
timeout-minutes: 30
shell: bash
run: |
set -euo pipefail
SHIM="$RUNNER_TEMP/osn-shim/shim.app"; rm -rf "$RUNNER_TEMP/osn-shim"; mkdir -p "$SHIM"
ditto "${{env.SLFullDistributePath}}" "$SHIM/distribute"
OSN_DIR="$SHIM/distribute/${{env.InstallPath}}"
# Point the tests at the copy instead of the install prefix baked in by CMake.
printf "export * from '%s/index';\n\nconst wd: string = \"%s\";\nexport {wd};\n" "$OSN_DIR" "$OSN_DIR" > tests/osn-tests/osn/index.ts
cat tests/osn-tests/osn/index.ts
yarn run test:ci
env:
SLOBS_BE_STREAMKEY: ${{secrets.testsStreamKey}}
SLOBS_TEST_USER_POOL_TOKEN: ${{secrets.testsUserPoolToken}}
OSN_ACCESS_KEY_ID: ${{secrets.AWS_RELEASE_ACCESS_KEY_ID}}
OSN_SECRET_ACCESS_KEY: ${{secrets.AWS_RELEASE_SECRET_ACCESS_KEY}}
RELEASE_NAME: ${{matrix.ReleaseName}}
CI: true
OSN_PROBE_DISABLE_MAIN_BUNDLE_FIX: '1'
# Run even after test failures so the PR still gets the flaky summary.
- name: Publish flaky test check
if: ${{ always() }}
Expand Down Expand Up @@ -349,6 +423,7 @@ jobs:
id: run_tests
timeout-minutes: 20
continue-on-error: false
# TEMP: browser-source probe branch (test:ci runs only test_browser_source_load)
run: 'yarn run test:ci'
env:
SLOBS_BE_STREAMKEY: ${{secrets.testsStreamKey}}
Expand Down
57 changes: 57 additions & 0 deletions obs-studio-server/source/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <limits.h>
#include <stdlib.h>
#include <mach-o/dyld.h>
#include <string>
#endif

#define BUFFSIZE 512
Expand Down Expand Up @@ -130,6 +134,57 @@ static void Shutdown(void *data, const int64_t id, const std::vector<ipc::value>
}
} // namespace System

#ifdef __APPLE__
static bool HasAppBundleAncestor(const std::string &path)
{
size_t start = 0;
while (start < path.size()) {
size_t end = path.find('/', start);
if (end == std::string::npos)
end = path.size();
std::string_view component(path.data() + start, end - start);
if (component.size() > 4 && component.substr(component.size() - 4) == ".app")
return true;
start = end + 1;
}
return false;
}

static void SetCefMainBundlePath(const char *argv0)
{
// TEMP: probe-only gate
if (getenv("OSN_PROBE_DISABLE_MAIN_BUNDLE_FIX"))
return;

uint32_t size = 0;
_NSGetExecutablePath(nullptr, &size);
std::string exePath(size, '\0');
if (_NSGetExecutablePath(exePath.data(), &size) != 0)
return;

char realExe[PATH_MAX];
if (!realpath(exePath.c_str(), realExe))
return;
std::string exeDir(realExe);
if (HasAppBundleAncestor(exeDir))
return;
exeDir.erase(exeDir.rfind('/'));

char realHelper[PATH_MAX];
std::string helperPath = exeDir + "/../Frameworks/obs64 Helper.app";
if (!realpath(helperPath.c_str(), realHelper))
return;
struct stat sb;
if (stat(realHelper, &sb) != 0 || !S_ISDIR(sb.st_mode))
return;

// CEF children rendezvous only if they resolve the same outer bundle as obs64.
std::string bundleArg = std::string("--main-bundle-path=") + realHelper;
const char *args[] = {argv0, bundleArg.c_str()};
obs_set_cmdline_args(2, args);
}
#endif

int main(int argc, char *argv[])
{
#ifdef _WIN32
Expand All @@ -150,6 +205,8 @@ int main(int argc, char *argv[])
int out_err = open(slobsStdErrPath.data(), O_WRONLY | O_CREAT | O_DSYNC);
}

SetCefMainBundlePath(argv[0]);

g_util_osx = new UtilInt();
g_util_osx->init();
#endif
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"test:unit": "node ci/run-unit-tests.js",
"test:integration": "electron-mocha -t 80000 --js-flags=\"--expose-gc\" --color -r ts-node/register tests/osn-tests/src/**/*.ts --reporter tests/osn-tests/util/list-reporter.js",
"test": "yarn run test:unit && yarn run test:integration",
"test:ci": "yarn run test:unit && yarn run test:integration --retries 2"
"test:ci": "electron-mocha -t 80000 --js-flags=\"--expose-gc\" --color -r ts-node/register tests/osn-tests/src/test_browser_source_load.ts --reporter tests/osn-tests/util/list-reporter.js"
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.0.0",
Expand Down
204 changes: 204 additions & 0 deletions tests/osn-tests/src/test_browser_source_load.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import 'mocha';
import { expect } from 'chai';
import * as fs from 'fs';
import * as http from 'http';
import * as os from 'os';
import * as path from 'path';
import { execSync } from 'child_process';
import { AddressInfo } from 'net';
import * as osn from '../osn';
import { logInfo, logEmptyLine } from '../util/logger';
import { OBSHandler } from '../util/obs_handler';
import { deleteConfigFiles, sleep } from '../util/general';

const testName = 'browser-source-load';
const loadTimeoutMs = 30000;

interface IRequestRecord {
path: string;
time: number;
}

const pageHtml = '<!DOCTYPE html><html><body><script src="/app.js"></script></body></html>';
const appJs = "fetch('/report?ok=1');";

describe(testName, () => {
let obs: OBSHandler;
let server: http.Server;
let pageUrl: string;
let requests: IRequestRecord[] = [];
let scene: osn.IScene;
let source: osn.IInput;
let hasTestFailed = false;

const startTime = Date.now();

function count(requestPath: string): number {
return requests.filter((r) => r.path.split('?')[0] === requestPath).length;
}

function describeRequests(): string {
if (requests.length === 0) {
return 'no requests seen';
}
return requests.map((r) => `+${r.time - startTime}ms ${r.path}`).join(', ');
}

async function waitFor(condition: () => boolean, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (condition()) {
return true;
}
await sleep(100);
}
return condition();
}

function runDiagnostics() {
if (process.platform !== 'darwin') {
return;
}
try {
const ps = execSync('ps -axo pid,ppid,command', { encoding: 'utf8' });
const lines = ps.split('\n').filter((line) => line.includes('obs64'));
logInfo(testName, 'obs64 processes:\n' + (lines.join('\n') || '(none)'));
} catch (e) {
logInfo(testName, 'ps failed: ' + e);
}

const frameworkDirs = [
path.join(osn.wd, 'Frameworks'),
path.join(osn.wd, '..', '..', 'Contents', 'Frameworks'),
];
for (const dir of frameworkDirs) {
const resolved = path.resolve(dir);
if (fs.existsSync(resolved)) {
logInfo(testName, `${resolved}:\n` + fs.readdirSync(resolved).join('\n'));
} else {
logInfo(testName, `${resolved} does not exist`);
}
}

const reportsDir = path.join(os.homedir(), 'Library', 'Logs', 'DiagnosticReports');
if (fs.existsSync(reportsDir)) {
const reports = fs.readdirSync(reportsDir).filter((name) => name.includes('obs64'));
logInfo(testName, `${reportsDir} obs64 reports:\n` + (reports.join('\n') || '(none)'));
} else {
logInfo(testName, `${reportsDir} does not exist`);
}
}

function failWithStage(stage: string, reached: string): never {
runDiagnostics();
throw new Error(`Browser source did not request ${stage}; reached stage: ${reached}. Requests: ${describeRequests()}`);
}

async function expectLoadSequence(documentCount: number) {
if (!(await waitFor(() => count('/') >= documentCount, loadTimeoutMs))) {
failWithStage('the document', 'none');
}
if (!(await waitFor(() => count('/app.js') >= documentCount, loadTimeoutMs))) {
failWithStage('/app.js', 'document');
}
if (!(await waitFor(() => count('/report') >= documentCount, loadTimeoutMs))) {
failWithStage('/report', 'app.js');
}
}

before(async function () {
logInfo(testName, 'Starting ' + testName + ' tests');

server = http.createServer((req, res) => {
const reqPath = req.url || '';
requests.push({ path: reqPath, time: Date.now() });
const pathname = reqPath.split('?')[0];
if (pathname === '/') {
res.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' });
res.end(pageHtml);
} else if (pathname === '/app.js') {
res.writeHead(200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-store' });
res.end(appJs);
} else {
res.writeHead(200, { 'Content-Type': 'text/plain', 'Cache-Control': 'no-store' });
res.end('ok');
}
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
pageUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}/`;
logInfo(testName, 'Fixture server listening at ' + pageUrl);

deleteConfigFiles();
obs = new OBSHandler(testName);
});

after(async function () {
if (source) {
source.release();
source = null;
}
if (scene) {
scene.getItems().forEach((item) => item.remove());
osn.Global.setOutputSource(0, null as any);
scene.release();
scene = null;
}

obs.shutdown();

if (hasTestFailed === true) {
logInfo(testName, 'One or more test cases failed. Uploading cache');
await obs.uploadTestCache();
}

await new Promise<void>((resolve) => server.close(() => resolve()));

obs = null;
deleteConfigFiles();
logInfo(testName, 'Finished ' + testName + ' tests');
logEmptyLine();
});

afterEach(function () {
if (this.currentTest.state == 'failed') {
hasTestFailed = true;
}
});

it('Browser source loads its page and runs its script', async function () {
this.timeout(120000);

scene = osn.SceneFactory.create('browser-load-scene');
source = osn.InputFactory.create('browser_source', 'browser-load-source', {
url: pageUrl,
width: 640,
height: 360,
});
expect(source).to.not.equal(undefined, 'Failed to create browser_source');
scene.add(source);
osn.Global.setOutputSource(0, scene);

await expectLoadSequence(1);
runDiagnostics();
});

it('Browser source reloads on refresh', async function () {
this.timeout(120000);
expect(source).to.not.equal(null, 'Browser source was not created by the previous test');

const documentsBefore = count('/');
const refreshButton: any = source.properties.get('refreshnocache');
if (refreshButton && typeof refreshButton.buttonClicked === 'function') {
logInfo(testName, 'Refreshing via refreshnocache button');
refreshButton.buttonClicked(source);
} else {
logInfo(testName, 'refreshnocache button not exposed, refreshing by changing url');
source.update({ url: pageUrl + '?refresh=1' });
}

if (!(await waitFor(() => count('/') > documentsBefore, loadTimeoutMs))) {
runDiagnostics();
throw new Error(`Browser source did not request the document again after refresh. Requests: ${describeRequests()}`);
}
});
});
Loading