diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 35db4126f..dee84aa46 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -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: @@ -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}} @@ -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() }} @@ -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}} diff --git a/obs-studio-server/source/main.cpp b/obs-studio-server/source/main.cpp index 4da5a8dbe..6e2480eed 100644 --- a/obs-studio-server/source/main.cpp +++ b/obs-studio-server/source/main.cpp @@ -89,6 +89,10 @@ #include #include #include +#include +#include +#include +#include #endif #define BUFFSIZE 512 @@ -130,6 +134,57 @@ static void Shutdown(void *data, const int64_t id, const std::vector } } // 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 @@ -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 diff --git a/package.json b/package.json index 4c81fe7c7..ca3877238 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tests/osn-tests/src/test_browser_source_load.ts b/tests/osn-tests/src/test_browser_source_load.ts new file mode 100644 index 000000000..9edac2547 --- /dev/null +++ b/tests/osn-tests/src/test_browser_source_load.ts @@ -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 = ''; +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 { + 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((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((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()}`); + } + }); +});